diff --git a/README.md b/README.md
index 1f91781..656e1b6 100644
--- a/README.md
+++ b/README.md
@@ -54,15 +54,19 @@ Requires Python 3.10+.
**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways:
- **Automatically** (recommended) — install the optional browser extra and use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed:
+
```bash
pip install "pyfragment[browser]"
```
+
```python
- from pyfragment.utils import get_cookies_from_browser
+ from pyfragment import get_cookies_from_browser
+
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
# result.cookies — dict[str, str] to pass to FragmentClient
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
```
+
- **Manually** — install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or JSON string.
Refresh when you get authentication errors.
diff --git a/examples/auctions/search_gifts.py b/examples/auctions/search_gifts.py
index dafcaac..5d0a9bd 100644
--- a/examples/auctions/search_gifts.py
+++ b/examples/auctions/search_gifts.py
@@ -10,9 +10,10 @@ Use next_offset for pagination.
import asyncio
import json
-from pyfragment import FragmentClient, GiftsResult
from pyfragment.utils import get_cookies_from_browser # noqa: F401
+from pyfragment import FragmentClient, GiftsResult
+
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/auctions/search_numbers.py b/examples/auctions/search_numbers.py
index f3f5a36..2d11c17 100644
--- a/examples/auctions/search_numbers.py
+++ b/examples/auctions/search_numbers.py
@@ -9,9 +9,10 @@ Use next_offset_id for pagination.
import asyncio
import json
-from pyfragment import FragmentClient, NumbersResult
from pyfragment.utils import get_cookies_from_browser # noqa: F401
+from pyfragment import FragmentClient, NumbersResult
+
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/auctions/search_usernames.py b/examples/auctions/search_usernames.py
index 5fc3e13..2fc2679 100644
--- a/examples/auctions/search_usernames.py
+++ b/examples/auctions/search_usernames.py
@@ -9,9 +9,10 @@ Use next_offset_id for pagination.
import asyncio
import json
-from pyfragment import FragmentClient, UsernamesResult
from pyfragment.utils import get_cookies_from_browser # noqa: F401
+from pyfragment import FragmentClient, UsernamesResult
+
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/client/raw_api_call.py b/examples/client/raw_api_call.py
index 4996d90..350e5c7 100644
--- a/examples/client/raw_api_call.py
+++ b/examples/client/raw_api_call.py
@@ -11,9 +11,10 @@ Defaults to the Fragment base URL.
import asyncio
-from pyfragment import FragmentClient
from pyfragment.utils import get_cookies_from_browser # noqa: F401
+from pyfragment import FragmentClient
+
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/client/wallet_info.py b/examples/client/wallet_info.py
index bfc6620..5646c7a 100644
--- a/examples/client/wallet_info.py
+++ b/examples/client/wallet_info.py
@@ -7,9 +7,10 @@ wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
import asyncio
-from pyfragment import FragmentClient
from pyfragment.utils import get_cookies_from_browser # noqa: F401
+from pyfragment import FragmentClient
+
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/numbers/manage_number.py b/examples/numbers/manage_number.py
index 80f1a3b..fb2160a 100644
--- a/examples/numbers/manage_number.py
+++ b/examples/numbers/manage_number.py
@@ -8,9 +8,10 @@ Use terminate_sessions() to forcefully end all active Telegram sessions.
import asyncio
-from pyfragment import AnonymousNumberError, FragmentClient
from pyfragment.utils import get_cookies_from_browser # noqa: F401
+from pyfragment import AnonymousNumberError, FragmentClient
+
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py
index 5e4c457..831d7e1 100644
--- a/examples/purchase/recharge_ads_balance.py
+++ b/examples/purchase/recharge_ads_balance.py
@@ -7,13 +7,14 @@ Your wallet must satisfy the current minimum TON threshold and transaction cost.
import asyncio
+from pyfragment.utils import get_cookies_from_browser # noqa: F401
+
from pyfragment import (
AdsRechargeResult,
ConfigurationError,
FragmentClient,
WalletError,
)
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py
index 9fb8565..418dc33 100644
--- a/examples/purchase/run_premium_giveaway.py
+++ b/examples/purchase/run_premium_giveaway.py
@@ -9,9 +9,10 @@ Channel can be "@channel", "channel", or "https://t.me/channel".
import asyncio
-from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401
+from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
+
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py
index 7f98ed9..1a02c88 100644
--- a/examples/purchase/run_stars_giveaway.py
+++ b/examples/purchase/run_stars_giveaway.py
@@ -9,9 +9,10 @@ Channel can be "@channel", "channel", or "https://t.me/channel".
import asyncio
-from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401
+from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
+
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py
index a28cfa7..e17bba9 100644
--- a/examples/purchase/send_premium.py
+++ b/examples/purchase/send_premium.py
@@ -9,9 +9,10 @@ Username can be "@username", "username", or "https://t.me/username".
import asyncio
-from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401
+from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
+
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py
index dbe5664..7e0ba6e 100644
--- a/examples/purchase/send_stars.py
+++ b/examples/purchase/send_stars.py
@@ -9,9 +9,10 @@ Username can be "@username", "username", or "https://t.me/username".
import asyncio
-from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401
+from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
+
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/topup_ton_balance.py b/examples/purchase/topup_ton_balance.py
index c4fa58f..0e5c279 100644
--- a/examples/purchase/topup_ton_balance.py
+++ b/examples/purchase/topup_ton_balance.py
@@ -9,13 +9,14 @@ Your wallet must satisfy the current minimum TON threshold and transaction cost.
import asyncio
+from pyfragment.utils import get_cookies_from_browser # noqa: F401
+
from pyfragment import (
ConfigurationError,
FragmentClient,
UserNotFoundError,
WalletError,
)
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py
index 78c492b..73c2732 100644
--- a/pyfragment/__init__.py
+++ b/pyfragment/__init__.py
@@ -6,39 +6,30 @@
from importlib.metadata import version
from pyfragment.client import FragmentClient
-from pyfragment.types import (
- AdsRechargeResult,
- AdsTopupResult,
+from pyfragment.core.cookies import get_cookies_from_browser
+from pyfragment.exceptions import (
AnonymousNumberError,
ClientError,
ConfigurationError,
CookieError,
- CookieResult,
FragmentAPIError,
- # exceptions
FragmentError,
FragmentPageError,
- GiftsResult,
- LoginCodeResult,
- NumbersResult,
OperationError,
ParseError,
- # literal types
- PaymentMethod,
- PremiumGiveawayResult,
- PremiumResult,
- StarsGiveawayResult,
- # results
- StarsResult,
- TerminateSessionsResult,
TransactionError,
UnexpectedError,
- UsernamesResult,
UserNotFoundError,
VerificationError,
WalletError,
- WalletInfo,
)
+from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
+from pyfragment.models.cookies import CookieResult
+from pyfragment.models.enums import PaymentMethod
+from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
+from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
+from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
+from pyfragment.models.wallet import WalletInfo
__version__: str = version("pyfragment")
@@ -76,4 +67,5 @@ __all__ = [
"UnexpectedError",
# literal types
"PaymentMethod",
+ "get_cookies_from_browser",
]
diff --git a/pyfragment/client.py b/pyfragment/client.py
index a35b9b8..78d11e1 100644
--- a/pyfragment/client.py
+++ b/pyfragment/client.py
@@ -5,46 +5,22 @@ from typing import Any, cast, get_args
import httpx
-from pyfragment.methods import (
- get_login_code,
- giveaway_premium,
- giveaway_stars,
- purchase_premium,
- purchase_stars,
- recharge_ads,
- search_gifts,
- search_numbers,
- search_usernames,
- terminate_sessions,
- toggle_login_codes,
- topup_ton,
-)
-from pyfragment.types import (
- AdsRechargeResult,
- AdsTopupResult,
- ConfigurationError,
- CookieError,
- GiftsResult,
- LoginCodeResult,
- NumbersResult,
- PremiumGiveawayResult,
- PremiumResult,
- StarsGiveawayResult,
- StarsResult,
- TerminateSessionsResult,
- UsernamesResult,
- WalletInfo,
-)
-from pyfragment.types.constants import (
- BASE_HEADERS,
- DEFAULT_TIMEOUT,
- FRAGMENT_BASE_URL,
- REQUIRED_COOKIE_KEYS,
- PaymentMethod,
- WalletVersion,
-)
-from pyfragment.utils.api import fragment_request, get_fragment_hash
-from pyfragment.utils.wallet import get_wallet_info
+from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
+from pyfragment.core.transport import fragment_request, get_fragment_hash
+from pyfragment.domains.ads.service import AdsService
+from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
+from pyfragment.domains.giveaways.service import GiveawaysService
+from pyfragment.domains.marketplace.service import MarketplaceService
+from pyfragment.domains.purchases.service import PurchasesService
+from pyfragment.domains.wallet.info import get_wallet_info
+from pyfragment.domains.wallet.service import WalletService
+from pyfragment.exceptions import ConfigurationError, CookieError
+from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
+from pyfragment.models.enums import PaymentMethod, WalletVersion
+from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
+from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
+from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
+from pyfragment.models.wallet import WalletInfo
class FragmentClient:
@@ -120,6 +96,12 @@ class FragmentClient:
self.cookies: dict[str, Any] = cast(dict[str, Any], cookies)
self.wallet_version: WalletVersion = version # type: ignore[assignment]
self.timeout: float = timeout
+ self.marketplace = MarketplaceService(self)
+ self.purchases = PurchasesService(self)
+ self.giveaways = GiveawaysService(self)
+ self.wallet = WalletService(self)
+ self.anonymous_numbers = AnonymousNumbersService(self)
+ self.ads = AdsService(self)
async def __aenter__(self) -> FragmentClient:
return self
@@ -148,7 +130,7 @@ class FragmentClient:
Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
"""
- return await purchase_premium(self, username, months, show_sender, payment_method)
+ return await self.purchases.purchase_premium(username, months, show_sender=show_sender, payment_method=payment_method)
async def purchase_stars(
self,
@@ -168,7 +150,7 @@ class FragmentClient:
Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
"""
- return await purchase_stars(self, username, amount, show_sender, payment_method)
+ return await self.purchases.purchase_stars(username, amount, show_sender=show_sender, payment_method=payment_method)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up TON to a recipient's Telegram balance.
@@ -181,7 +163,7 @@ class FragmentClient:
Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
"""
- return await topup_ton(self, username, amount, show_sender)
+ return await self.wallet.topup_ton(username, amount, show_sender=show_sender)
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account.
@@ -194,7 +176,7 @@ class FragmentClient:
Returns:
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
"""
- return await recharge_ads(self, account, amount)
+ return await self.ads.recharge_ads(account, amount)
async def get_wallet(self) -> WalletInfo:
"""Return the address, state, and balances of the wallet.
@@ -225,7 +207,7 @@ class FragmentClient:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
- return await giveaway_stars(self, channel, winners, amount, payment_method)
+ return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method)
async def giveaway_premium(
self,
@@ -246,7 +228,7 @@ class FragmentClient:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
- return await giveaway_premium(self, channel, winners, months, payment_method)
+ return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method)
async def get_login_code(self, number: str) -> LoginCodeResult:
"""Fetch the current pending login code for an anonymous number.
@@ -258,7 +240,7 @@ class FragmentClient:
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
and ``active_sessions`` count.
"""
- return await get_login_code(self, number)
+ return await self.anonymous_numbers.get_login_code(number)
async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
"""Enable or disable login code delivery for an anonymous number.
@@ -267,7 +249,7 @@ class FragmentClient:
number: Phone number with or without leading ``+``.
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
"""
- return await toggle_login_codes(self, number, can_receive)
+ return await self.anonymous_numbers.toggle_login_codes(number, can_receive)
async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
"""Terminate all active Telegram sessions for an anonymous number.
@@ -281,7 +263,7 @@ class FragmentClient:
Raises:
AnonymousNumberError: If the number is not owned by this account or has no active sessions.
"""
- return await terminate_sessions(self, number)
+ return await self.anonymous_numbers.terminate_sessions(number)
async def search_usernames(
self,
@@ -305,7 +287,7 @@ class FragmentClient:
:class:`UsernamesResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
"""
- return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id)
+ return await self.marketplace.search_usernames(query, sort=sort, filter=filter, offset_id=offset_id)
async def search_numbers(
self,
@@ -329,7 +311,7 @@ class FragmentClient:
:class:`NumbersResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
"""
- return await search_numbers(self, query, sort=sort, filter=filter, offset_id=offset_id)
+ return await self.marketplace.search_numbers(query, sort=sort, filter=filter, offset_id=offset_id)
async def search_gifts(
self,
@@ -361,8 +343,8 @@ class FragmentClient:
:class:`GiftsResult` with ``items`` (parsed list of item dicts)
and ``next_offset`` (``None`` on the last page).
"""
- return await search_gifts(
- self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
+ return await self.marketplace.search_gifts(
+ query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
)
async def call(
diff --git a/pyfragment/core/__init__.py b/pyfragment/core/__init__.py
new file mode 100644
index 0000000..87ec652
--- /dev/null
+++ b/pyfragment/core/__init__.py
@@ -0,0 +1 @@
+"""Low-level transport and shared helpers for pyfragment."""
diff --git a/pyfragment/types/constants.py b/pyfragment/core/constants.py
similarity index 73%
rename from pyfragment/types/constants.py
rename to pyfragment/core/constants.py
index 029c55d..23a3ef0 100644
--- a/pyfragment/types/constants.py
+++ b/pyfragment/core/constants.py
@@ -1,34 +1,21 @@
from __future__ import annotations
import json
-from typing import Any, Literal
+from typing import Any
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
-# Payment methods
-PaymentMethod = Literal["ton", "usdt_ton"]
-
-# Single source of truth for supported wallet versions
-WalletVersion = Literal["V4R2", "V5R1"]
-
-# Wallet class map — used to resolve the correct contract from WALLET_VERSION
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
-# Minimum TON balance threshold required for payment flows.
MIN_TON_BALANCE: float = 0.33
-
-# USDT (TON) jetton metadata used for payment-method balance checks.
USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
MIN_USDT_BALANCE: float = 0.75
-# Default HTTP request timeout in seconds.
DEFAULT_TIMEOUT: float = 30.0
-# Required Fragment session cookie keys
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
-# Fragment domain and page URLs
-FRAGMENT_DOMAIN: str = "fragment.com" # for rookiepy
+FRAGMENT_DOMAIN: str = "fragment.com"
FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}"
STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway"
@@ -38,7 +25,6 @@ ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts"
-# Browsers supported by get_cookies_from_browser()
SUPPORTED_BROWSERS: frozenset[str] = frozenset(
{
"arc",
@@ -57,7 +43,6 @@ SUPPORTED_BROWSERS: frozenset[str] = frozenset(
}
)
-# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
DEVICE: str = json.dumps(
{
"platform": "iphone",
@@ -72,8 +57,6 @@ DEVICE: str = json.dumps(
}
)
-# Base HTTP headers — shared across all Fragment API requests.
-# Each method merges these with its own "referer" and "x-aj-referer".
BASE_HEADERS: dict[str, str] = {
"accept": "application/json, text/javascript, */*; q=0.01",
"accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7",
diff --git a/pyfragment/utils/cookies.py b/pyfragment/core/cookies.py
similarity index 59%
rename from pyfragment/utils/cookies.py
rename to pyfragment/core/cookies.py
index 49b76e6..2f680fd 100644
--- a/pyfragment/utils/cookies.py
+++ b/pyfragment/core/cookies.py
@@ -3,38 +3,19 @@ from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
-from pyfragment.types import CookieError
-from pyfragment.types import CookieResult as CookieResult
-from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
+from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
+from pyfragment.exceptions import CookieError
+from pyfragment.models.cookies import CookieResult
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
- """Extract Fragment session cookies directly from an installed browser.
-
- Reads the browser's on-disk cookie store (no extension required) and
- returns the four cookies required by :class:`~pyfragment.FragmentClient`
- along with the session expiry timestamp.
-
- Args:
- browser: Browser name to read cookies from — case-insensitive. Supported values:
- ``"chrome"`` (default), ``"firefox"``, ``"edge"``, ``"brave"``, ``"arc"``,
- ``"opera"``, ``"opera_gx"``, ``"chromium"``, ``"chromium_based"``,
- ``"firefox_based"``, ``"vivaldi"``, ``"librewolf"``, ``"safari"``.
-
- Returns:
- :class:`CookieResult` with ``.cookies`` (dict) and ``.expires`` (ISO 8601 string or ``None``).
-
- Raises:
- CookieError: If the browser is not supported, cookies cannot be read,
- or required keys are missing.
- """
key = browser.lower()
if key not in SUPPORTED_BROWSERS:
supported = ", ".join(sorted(SUPPORTED_BROWSERS))
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
try:
- import rookiepy
+ import rookiepy # type: ignore[import-not-found]
jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
except Exception as exc:
@@ -66,7 +47,4 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
if expires_dt < datetime.now(timezone.utc):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
- return CookieResult(
- cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
- expires=expires_iso,
- )
+ return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso)
diff --git a/pyfragment/utils/api.py b/pyfragment/core/transport.py
similarity index 55%
rename from pyfragment/utils/api.py
rename to pyfragment/core/transport.py
index da00f82..9ebeb32 100644
--- a/pyfragment/utils/api.py
+++ b/pyfragment/core/transport.py
@@ -7,8 +7,8 @@ from typing import Any, cast
import httpx
-from pyfragment.types import FragmentPageError, ParseError, VerificationError
-from pyfragment.types.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
+from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
+from pyfragment.exceptions import FragmentPageError, ParseError, VerificationError
async def get_fragment_hash(
@@ -17,25 +17,6 @@ async def get_fragment_hash(
page_url: str,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
- """Fetch the API hash from a Fragment page.
-
- Fragment embeds a short-lived hash in each page's HTML that must be
- included in every subsequent API request. This function loads the page
- as a real browser navigation (not XHR) so Fragment returns full HTML.
-
- Args:
- cookies: Active Fragment session cookies.
- headers: Base headers for the relevant Fragment page.
- page_url: URL of the Fragment page to fetch the hash from.
- timeout: HTTP request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``.
-
- Returns:
- Lowercase hex hash string.
-
- Raises:
- FragmentPageError: If the page returns a non-200 status or the hash
- is not found in the response HTML.
- """
page_headers = {
k: v
for k, v in headers.items()
@@ -65,18 +46,6 @@ async def get_fragment_hash(
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
- """Parse a Fragment API JSON response.
-
- Args:
- response: The HTTP response object.
- context: Human-readable name of the API method, used in error messages.
-
- Returns:
- Parsed response as a dict.
-
- Raises:
- ParseError: If the response body cannot be decoded as JSON.
- """
try:
return cast(dict[str, Any], response.json())
except Exception as exc:
@@ -89,21 +58,6 @@ async def fragment_request(
headers: dict[str, str],
data: dict[str, Any],
) -> dict[str, Any]:
- """POST a single request to the Fragment API.
-
- Builds the ``/api?hash=`` URL, sends the request, and returns the
- parsed JSON body. Use this for every API method call — search,
- init, state updates, etc.
-
- Args:
- session: Active httpx session with Fragment cookies.
- fragment_hash: Short-lived hash from the Fragment page HTML.
- headers: Page-specific HTTP headers.
- data: Form data payload; must include a ``"method"`` key.
-
- Returns:
- Parsed API response as a dict.
- """
for attempt in range(3):
resp = await session.post(
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
@@ -127,21 +81,6 @@ async def execute_transaction_request(
tx_data: dict[str, Any],
fragment_hash: str,
) -> dict[str, Any]:
- """Post a transaction request to the Fragment API.
-
- Args:
- session: Active httpx session with Fragment cookies.
- headers: Page-specific HTTP headers.
- tx_data: Form data payload for the API method.
- fragment_hash: Short-lived hash from the Fragment page.
-
- Returns:
- Parsed API response dict containing transaction data.
-
- Raises:
- VerificationError: If Fragment requires KYC verification.
- ParseError: If the response cannot be parsed.
- """
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
if transaction.get("need_verify"):
diff --git a/pyfragment/domains/__init__.py b/pyfragment/domains/__init__.py
new file mode 100644
index 0000000..858b79e
--- /dev/null
+++ b/pyfragment/domains/__init__.py
@@ -0,0 +1 @@
+"""Domain service package for pyfragment."""
diff --git a/pyfragment/domains/ads/__init__.py b/pyfragment/domains/ads/__init__.py
new file mode 100644
index 0000000..c532ac4
--- /dev/null
+++ b/pyfragment/domains/ads/__init__.py
@@ -0,0 +1 @@
+"""Ads domain services."""
diff --git a/pyfragment/methods/recharge_ads.py b/pyfragment/domains/ads/recharge.py
similarity index 61%
rename from pyfragment/methods/recharge_ads.py
rename to pyfragment/domains/ads/recharge.py
index 19cf098..9879ca7 100644
--- a/pyfragment/methods/recharge_ads.py
+++ b/pyfragment/domains/ads/recharge.py
@@ -3,38 +3,17 @@ from __future__ import annotations
import json
from typing import TYPE_CHECKING
-from pyfragment.types import (
- AdsRechargeResult,
- ConfigurationError,
- FragmentAPIError,
- FragmentError,
- UnexpectedError,
- VerificationError,
-)
-from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
-from pyfragment.utils import get_account_info, process_transaction
+from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
+from pyfragment.domains.wallet.info import get_account_info
+from pyfragment.domains.wallet.transaction import process_transaction
+from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
+from pyfragment.models.payments import AdsRechargeResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
- """Add funds to your own Telegram Ads account.
-
- Args:
- client: Authenticated :class:`FragmentClient` instance.
- account: Your Fragment Ads account identifier — the channel or bot username
- the Ads account is linked to (e.g. ``"@mychannel"``).
- amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
-
- Returns:
- :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
-
- Raises:
- ConfigurationError: If ``amount`` is not a valid integer in the allowed range.
- FragmentAPIError: If the Fragment API returns an error.
- UnexpectedError: For any other unexpected failure.
- """
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
diff --git a/pyfragment/domains/ads/service.py b/pyfragment/domains/ads/service.py
new file mode 100644
index 0000000..d55de1f
--- /dev/null
+++ b/pyfragment/domains/ads/service.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from pyfragment.domains.ads.recharge import recharge_ads
+from pyfragment.domains.base import BaseService
+from pyfragment.models.payments import AdsRechargeResult
+
+if TYPE_CHECKING:
+ pass
+
+
+class AdsService(BaseService):
+ async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
+ return await recharge_ads(self._client, account, amount)
diff --git a/pyfragment/domains/anonymous_numbers/__init__.py b/pyfragment/domains/anonymous_numbers/__init__.py
new file mode 100644
index 0000000..b1aa820
--- /dev/null
+++ b/pyfragment/domains/anonymous_numbers/__init__.py
@@ -0,0 +1 @@
+"""Anonymous numbers domain services."""
diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/domains/anonymous_numbers/number.py
similarity index 59%
rename from pyfragment/methods/anonymous_number.py
rename to pyfragment/domains/anonymous_numbers/number.py
index fc79d29..9a84c48 100644
--- a/pyfragment/methods/anonymous_number.py
+++ b/pyfragment/domains/anonymous_numbers/number.py
@@ -3,16 +3,10 @@ from __future__ import annotations
import html
from typing import TYPE_CHECKING
-from pyfragment.types import (
- AnonymousNumberError,
- FragmentAPIError,
- FragmentError,
- LoginCodeResult,
- TerminateSessionsResult,
- UnexpectedError,
-)
-from pyfragment.types.constants import NUMBERS_PAGE
-from pyfragment.utils import parse_login_code
+from pyfragment.core.constants import NUMBERS_PAGE
+from pyfragment.domains.anonymous_numbers.parser import parse_login_code
+from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError
+from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -23,20 +17,6 @@ def _strip_plus(number: str) -> str:
async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult:
- """Fetch the current pending login code for an anonymous number.
-
- Args:
- client: Authenticated :class:`FragmentClient` instance.
- number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
-
- Returns:
- :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if no pending code),
- and ``active_sessions`` count.
-
- Raises:
- FragmentAPIError: If the Fragment API returns an error.
- UnexpectedError: For any other unexpected failure.
- """
try:
clean = _strip_plus(number)
result = await client.call(
@@ -59,17 +39,6 @@ async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult
async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None:
- """Enable or disable login code delivery for an anonymous number.
-
- Args:
- client: Authenticated :class:`FragmentClient` instance.
- number: Phone number with or without leading ``+``.
- can_receive: ``True`` to allow receiving codes, ``False`` to block them.
-
- Raises:
- FragmentAPIError: If the Fragment API returns an error.
- UnexpectedError: For any other unexpected failure.
- """
try:
clean = _strip_plus(number)
result = await client.call(
@@ -88,24 +57,6 @@ async def toggle_login_codes(client: FragmentClient, number: str, can_receive: b
async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult:
- """Terminate all active Telegram sessions for an anonymous number.
-
- This is a two-step operation: Fragment first returns a confirmation hash,
- which is then submitted to confirm the termination.
-
- Args:
- client: Authenticated :class:`FragmentClient` instance.
- number: Phone number with or without leading ``+``.
-
- Returns:
- :class:`TerminateSessionsResult` with ``number`` and ``message``.
-
- Raises:
- AnonymousNumberError: If the number is not owned by this account or has no active sessions,
- or if Fragment returns an error during termination.
- FragmentAPIError: If the Fragment API returns an error.
- UnexpectedError: For any other unexpected failure.
- """
try:
clean = _strip_plus(number)
diff --git a/pyfragment/domains/anonymous_numbers/parser.py b/pyfragment/domains/anonymous_numbers/parser.py
new file mode 100644
index 0000000..d7dacee
--- /dev/null
+++ b/pyfragment/domains/anonymous_numbers/parser.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+import re
+
+CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
+ROW_RE = re.compile(r"
]")
+
+
+def parse_login_code(html: str) -> tuple[str | None, int]:
+ match = CODE_RE.search(html)
+ code = match.group(1).strip() if match else None
+ active_sessions = len(ROW_RE.findall(html))
+ return code, active_sessions
diff --git a/pyfragment/domains/anonymous_numbers/service.py b/pyfragment/domains/anonymous_numbers/service.py
new file mode 100644
index 0000000..28c7ec5
--- /dev/null
+++ b/pyfragment/domains/anonymous_numbers/service.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes
+from pyfragment.domains.base import BaseService
+from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
+
+if TYPE_CHECKING:
+ pass
+
+
+class AnonymousNumbersService(BaseService):
+ async def get_login_code(self, number: str) -> LoginCodeResult:
+ return await get_login_code(self._client, number)
+
+ async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
+ return await toggle_login_codes(self._client, number, can_receive)
+
+ async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
+ return await terminate_sessions(self._client, number)
diff --git a/pyfragment/domains/base.py b/pyfragment/domains/base.py
new file mode 100644
index 0000000..01d5ca5
--- /dev/null
+++ b/pyfragment/domains/base.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from pyfragment.client import FragmentClient
+
+
+class BaseService:
+ def __init__(self, client: FragmentClient) -> None:
+ self._client = client
diff --git a/pyfragment/domains/giveaways/__init__.py b/pyfragment/domains/giveaways/__init__.py
new file mode 100644
index 0000000..21a406f
--- /dev/null
+++ b/pyfragment/domains/giveaways/__init__.py
@@ -0,0 +1 @@
+"""Giveaways domain services."""
diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py
new file mode 100644
index 0000000..a7cf68d
--- /dev/null
+++ b/pyfragment/domains/giveaways/giveaway.py
@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+import json
+from typing import TYPE_CHECKING, get_args
+
+from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
+from pyfragment.domains.payments import parse_required_payment_amount
+from pyfragment.domains.wallet.info import get_account_info
+from pyfragment.domains.wallet.transaction import process_transaction
+from pyfragment.exceptions import (
+ ConfigurationError,
+ FragmentAPIError,
+ FragmentError,
+ UnexpectedError,
+ UserNotFoundError,
+ VerificationError,
+)
+from pyfragment.models.enums import PaymentMethod
+from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
+
+if TYPE_CHECKING:
+ from pyfragment.client import FragmentClient
+
+
+async def giveaway_stars(
+ client: FragmentClient,
+ channel: str,
+ winners: int,
+ amount: int,
+ payment_method: PaymentMethod = "ton",
+) -> StarsGiveawayResult:
+ if not isinstance(winners, int) or not (1 <= winners <= 5):
+ raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
+ if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
+ raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
+ if payment_method not in get_args(PaymentMethod):
+ raise ConfigurationError(
+ ConfigurationError.INVALID_PAYMENT_METHOD.format(
+ method=payment_method,
+ supported=", ".join(sorted(get_args(PaymentMethod))),
+ )
+ )
+
+ try:
+ result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
+ recipient = result.get("found", {}).get("recipient")
+ if not recipient:
+ raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
+
+ result = await client.call(
+ "initGiveawayStarsRequest",
+ {
+ "recipient": recipient,
+ "quantity": str(winners),
+ "stars": str(amount),
+ "payment_method": payment_method,
+ },
+ page_url=STARS_GIVEAWAY_PAGE,
+ )
+ required_payment_amount = parse_required_payment_amount(result)
+ req_id = result.get("req_id")
+ if not req_id:
+ raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
+
+ account = await get_account_info(client)
+ transaction = await client.call(
+ "getGiveawayStarsLink",
+ {
+ "account": json.dumps(account),
+ "device": DEVICE,
+ "transaction": 1,
+ "id": req_id,
+ },
+ page_url=STARS_GIVEAWAY_PAGE,
+ )
+ if transaction.get("need_verify"):
+ raise VerificationError(VerificationError.KYC_REQUIRED)
+
+ tx_hash = await process_transaction(
+ client,
+ transaction,
+ payment_method=payment_method,
+ required_payment_amount=required_payment_amount,
+ )
+ return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount)
+
+ except FragmentError:
+ raise
+ except Exception as exc:
+ raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+
+
+async def giveaway_premium(
+ client: FragmentClient,
+ channel: str,
+ winners: int,
+ months: int = 3,
+ payment_method: PaymentMethod = "ton",
+) -> PremiumGiveawayResult:
+ if not isinstance(winners, int) or not (1 <= winners <= 24_000):
+ raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
+ if months not in (3, 6, 12):
+ raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
+ if payment_method not in get_args(PaymentMethod):
+ raise ConfigurationError(
+ ConfigurationError.INVALID_PAYMENT_METHOD.format(
+ method=payment_method,
+ supported=", ".join(sorted(get_args(PaymentMethod))),
+ )
+ )
+
+ try:
+ result = await client.call(
+ "searchPremiumGiveawayRecipient",
+ {"query": channel, "quantity": winners, "months": months},
+ page_url=PREMIUM_GIVEAWAY_PAGE,
+ )
+ recipient = result.get("found", {}).get("recipient")
+ if not recipient:
+ raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
+
+ result = await client.call(
+ "initGiveawayPremiumRequest",
+ {
+ "recipient": recipient,
+ "quantity": str(winners),
+ "months": str(months),
+ "payment_method": payment_method,
+ },
+ page_url=PREMIUM_GIVEAWAY_PAGE,
+ )
+ required_payment_amount = parse_required_payment_amount(result)
+ req_id = result.get("req_id")
+ if not req_id:
+ raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
+
+ account = await get_account_info(client)
+ transaction = await client.call(
+ "getGiveawayPremiumLink",
+ {
+ "account": json.dumps(account),
+ "device": DEVICE,
+ "transaction": 1,
+ "id": req_id,
+ },
+ page_url=PREMIUM_GIVEAWAY_PAGE,
+ )
+ if transaction.get("need_verify"):
+ raise VerificationError(VerificationError.KYC_REQUIRED)
+
+ tx_hash = await process_transaction(
+ client,
+ transaction,
+ payment_method=payment_method,
+ required_payment_amount=required_payment_amount,
+ )
+ return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months)
+
+ except FragmentError:
+ raise
+ except Exception as exc:
+ raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
diff --git a/pyfragment/domains/giveaways/service.py b/pyfragment/domains/giveaways/service.py
new file mode 100644
index 0000000..abbaefa
--- /dev/null
+++ b/pyfragment/domains/giveaways/service.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from pyfragment.domains.base import BaseService
+from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
+from pyfragment.models.enums import PaymentMethod
+from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
+
+if TYPE_CHECKING:
+ pass
+
+
+class GiveawaysService(BaseService):
+ async def giveaway_stars(
+ self,
+ channel: str,
+ winners: int,
+ amount: int,
+ payment_method: PaymentMethod = "ton",
+ ) -> StarsGiveawayResult:
+ return await giveaway_stars(self._client, channel, winners, amount, payment_method=payment_method)
+
+ async def giveaway_premium(
+ self,
+ channel: str,
+ winners: int,
+ months: int = 3,
+ payment_method: PaymentMethod = "ton",
+ ) -> PremiumGiveawayResult:
+ return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method)
diff --git a/pyfragment/domains/marketplace/__init__.py b/pyfragment/domains/marketplace/__init__.py
new file mode 100644
index 0000000..2d86908
--- /dev/null
+++ b/pyfragment/domains/marketplace/__init__.py
@@ -0,0 +1 @@
+"""Marketplace domain services."""
diff --git a/pyfragment/domains/marketplace/parser.py b/pyfragment/domains/marketplace/parser.py
new file mode 100644
index 0000000..0bd681d
--- /dev/null
+++ b/pyfragment/domains/marketplace/parser.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+ROW_BLOCK_RE = re.compile(r'
]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)
', re.DOTALL)
+HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"')
+VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<')
+PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<")
+DATETIME_RE = re.compile(r'