diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67e66ce..7e3e246 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
---
+## [2026.3.0] — 2026-05-21
+
+### Changed
+
+- Internal architecture reorganized around explicit domain packages:
+ - TON account and balance helpers are now unified under `pyfragment.domains.tonapi.account`
+ - service wrappers and operation modules are aligned by domain (`ads`, `purchases`, `giveaways`, `anonymous_numbers`, `marketplace`, `tonapi`)
+- Package exports were cleaned up for domain and model packages (`__init__.py`) to provide clearer public symbols.
+- Examples and system tests were updated to follow current public import paths and project structure.
+
+### Fixed
+
+- `get_cookies_from_browser()` is now patch-friendly in tests (`pyfragment.core.cookies.rookiepy` can be mocked reliably).
+- Anonymous number `NOT_OWNED` error message wording was adjusted for test and backward-compatibility with existing matchers.
+
## [2026.2.3] — 2026-05-12
### Fixed
@@ -75,7 +90,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
- `get_cookies_from_browser(browser)` — extract Fragment session cookies directly from an installed browser (Chrome, Firefox, Edge, Brave, Arc, Opera, Safari, and more); no browser extension or manual copy-paste required
```python
- from pyfragment.utils import get_cookies_from_browser
+ from pyfragment import get_cookies_from_browser
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
client = FragmentClient(seed="...", api_key="...", cookies=result.cookies)
print(result.expires) # ISO 8601 expiry of stel_ssid, or None for session cookies
@@ -177,6 +192,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
- `py.typed` marker — full PEP 561 typing support for type-checkers
- `__repr__` on all result types for readable debug output
+[2026.3.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.0
[2026.2.3]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.3
[2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2
[2026.2.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.1
diff --git a/README.md b/README.md
index 7677515..3b4c4a6 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
[](https://github.com/bohd4nx/pyfragment/actions)
[](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE)
-[Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
+[Documentation](https://bohd4nx.gitbook.io/pyfragment/) · [Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
@@ -53,13 +53,20 @@ Requires Python 3.10+.
**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways:
-- **Automatically** (recommended) — use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed:
+- **Automatically** (recommended) — install the optional browser extra and use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed:
+
+ ```bash
+ pip install "pyfragment[browser]"
+ ```
+
```python
- from pyfragment.utils import get_cookies_from_browser
+ from pyfragment import get_cookies_from_browser
+
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
# result.cookies — dict[str, str] to pass to FragmentClient
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
```
+
- **Manually** — install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or JSON string.
Refresh when you get authentication errors.
diff --git a/examples/auctions/search_gifts.py b/examples/auctions/search_gifts.py
index dafcaac..e0c78df 100644
--- a/examples/auctions/search_gifts.py
+++ b/examples/auctions/search_gifts.py
@@ -11,7 +11,6 @@ import asyncio
import json
from pyfragment import FragmentClient, GiftsResult
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/auctions/search_numbers.py b/examples/auctions/search_numbers.py
index f3f5a36..9cc40e2 100644
--- a/examples/auctions/search_numbers.py
+++ b/examples/auctions/search_numbers.py
@@ -10,7 +10,6 @@ import asyncio
import json
from pyfragment import FragmentClient, NumbersResult
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/auctions/search_usernames.py b/examples/auctions/search_usernames.py
index 5fc3e13..b8c4ce3 100644
--- a/examples/auctions/search_usernames.py
+++ b/examples/auctions/search_usernames.py
@@ -10,7 +10,6 @@ import asyncio
import json
from pyfragment import FragmentClient, UsernamesResult
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/client/raw_api_call.py b/examples/client/raw_api_call.py
index 4996d90..066e755 100644
--- a/examples/client/raw_api_call.py
+++ b/examples/client/raw_api_call.py
@@ -12,7 +12,6 @@ Defaults to the Fragment base URL.
import asyncio
from pyfragment import FragmentClient
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/client/wallet_info.py b/examples/client/wallet_info.py
index bfc6620..7631288 100644
--- a/examples/client/wallet_info.py
+++ b/examples/client/wallet_info.py
@@ -8,7 +8,6 @@ wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
import asyncio
from pyfragment import FragmentClient
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/numbers/manage_number.py b/examples/numbers/manage_number.py
index 80f1a3b..b4b9702 100644
--- a/examples/numbers/manage_number.py
+++ b/examples/numbers/manage_number.py
@@ -9,7 +9,6 @@ Use terminate_sessions() to forcefully end all active Telegram sessions.
import asyncio
from pyfragment import AnonymousNumberError, FragmentClient
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py
index 5e4c457..7dc0162 100644
--- a/examples/purchase/recharge_ads_balance.py
+++ b/examples/purchase/recharge_ads_balance.py
@@ -13,7 +13,6 @@ from pyfragment import (
FragmentClient,
WalletError,
)
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py
index 9fb8565..63526c5 100644
--- a/examples/purchase/run_premium_giveaway.py
+++ b/examples/purchase/run_premium_giveaway.py
@@ -10,7 +10,6 @@ Channel can be "@channel", "channel", or "https://t.me/channel".
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py
index 7f98ed9..83dfc7a 100644
--- a/examples/purchase/run_stars_giveaway.py
+++ b/examples/purchase/run_stars_giveaway.py
@@ -10,7 +10,6 @@ Channel can be "@channel", "channel", or "https://t.me/channel".
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py
index a28cfa7..dab0d48 100644
--- a/examples/purchase/send_premium.py
+++ b/examples/purchase/send_premium.py
@@ -10,7 +10,6 @@ Username can be "@username", "username", or "https://t.me/username".
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py
index dbe5664..a155fc5 100644
--- a/examples/purchase/send_stars.py
+++ b/examples/purchase/send_stars.py
@@ -10,7 +10,6 @@ Username can be "@username", "username", or "https://t.me/username".
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/examples/purchase/topup_ton_balance.py b/examples/purchase/topup_ton_balance.py
index c4fa58f..e44731c 100644
--- a/examples/purchase/topup_ton_balance.py
+++ b/examples/purchase/topup_ton_balance.py
@@ -15,7 +15,6 @@ from pyfragment import (
UserNotFoundError,
WalletError,
)
-from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py
index 78c492b..ac06351 100644
--- a/pyfragment/__init__.py
+++ b/pyfragment/__init__.py
@@ -1,44 +1,30 @@
-# Copyright (c) 2026 bohd4nx
-#
-# This source code is licensed under the MIT License found in the
-# LICENSE file in the root directory of this source tree.
-
from importlib.metadata import version
from pyfragment.client import FragmentClient
-from pyfragment.types import (
- AdsRechargeResult,
- AdsTopupResult,
+from pyfragment.core.cookies import get_cookies_from_browser
+from pyfragment.exceptions import (
AnonymousNumberError,
ClientError,
ConfigurationError,
CookieError,
- CookieResult,
FragmentAPIError,
- # exceptions
FragmentError,
FragmentPageError,
- GiftsResult,
- LoginCodeResult,
- NumbersResult,
OperationError,
ParseError,
- # literal types
- PaymentMethod,
- PremiumGiveawayResult,
- PremiumResult,
- StarsGiveawayResult,
- # results
- StarsResult,
- TerminateSessionsResult,
TransactionError,
UnexpectedError,
- UsernamesResult,
UserNotFoundError,
VerificationError,
WalletError,
- WalletInfo,
)
+from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
+from pyfragment.models.cookies import CookieResult
+from pyfragment.models.enums import PaymentMethod
+from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
+from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
+from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
+from pyfragment.models.wallet import WalletInfo
__version__: str = version("pyfragment")
@@ -76,4 +62,5 @@ __all__ = [
"UnexpectedError",
# literal types
"PaymentMethod",
+ "get_cookies_from_browser",
]
diff --git a/pyfragment/client.py b/pyfragment/client.py
index a35b9b8..f452581 100644
--- a/pyfragment/client.py
+++ b/pyfragment/client.py
@@ -5,46 +5,21 @@ from typing import Any, cast, get_args
import httpx
-from pyfragment.methods import (
- get_login_code,
- giveaway_premium,
- giveaway_stars,
- purchase_premium,
- purchase_stars,
- recharge_ads,
- search_gifts,
- search_numbers,
- search_usernames,
- terminate_sessions,
- toggle_login_codes,
- topup_ton,
-)
-from pyfragment.types import (
- AdsRechargeResult,
- AdsTopupResult,
- ConfigurationError,
- CookieError,
- GiftsResult,
- LoginCodeResult,
- NumbersResult,
- PremiumGiveawayResult,
- PremiumResult,
- StarsGiveawayResult,
- StarsResult,
- TerminateSessionsResult,
- UsernamesResult,
- WalletInfo,
-)
-from pyfragment.types.constants import (
- BASE_HEADERS,
- DEFAULT_TIMEOUT,
- FRAGMENT_BASE_URL,
- REQUIRED_COOKIE_KEYS,
- PaymentMethod,
- WalletVersion,
-)
-from pyfragment.utils.api import fragment_request, get_fragment_hash
-from pyfragment.utils.wallet import get_wallet_info
+from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
+from pyfragment.core.transport import fragment_request, get_fragment_hash
+from pyfragment.domains.ads.service import AdsService
+from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
+from pyfragment.domains.giveaways.service import GiveawaysService
+from pyfragment.domains.marketplace.service import MarketplaceService
+from pyfragment.domains.purchases.service import PurchasesService
+from pyfragment.domains.tonapi.service import TonapiService
+from pyfragment.exceptions import ConfigurationError, CookieError
+from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
+from pyfragment.models.enums import PaymentMethod, WalletVersion
+from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
+from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
+from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
+from pyfragment.models.wallet import WalletInfo
class FragmentClient:
@@ -120,6 +95,12 @@ class FragmentClient:
self.cookies: dict[str, Any] = cast(dict[str, Any], cookies)
self.wallet_version: WalletVersion = version # type: ignore[assignment]
self.timeout: float = timeout
+ self.marketplace = MarketplaceService(self)
+ self.purchases = PurchasesService(self)
+ self.giveaways = GiveawaysService(self)
+ self.tonapi = TonapiService(self)
+ self.anonymous_numbers = AnonymousNumbersService(self)
+ self.ads = AdsService(self)
async def __aenter__(self) -> FragmentClient:
return self
@@ -148,7 +129,7 @@ class FragmentClient:
Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
"""
- return await purchase_premium(self, username, months, show_sender, payment_method)
+ return await self.purchases.purchase_premium(username, months, show_sender=show_sender, payment_method=payment_method)
async def purchase_stars(
self,
@@ -168,7 +149,7 @@ class FragmentClient:
Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
"""
- return await purchase_stars(self, username, amount, show_sender, payment_method)
+ return await self.purchases.purchase_stars(username, amount, show_sender=show_sender, payment_method=payment_method)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up TON to a recipient's Telegram balance.
@@ -181,7 +162,7 @@ class FragmentClient:
Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
"""
- return await topup_ton(self, username, amount, show_sender)
+ return await self.ads.topup_ton(username, amount, show_sender=show_sender)
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account.
@@ -194,7 +175,7 @@ class FragmentClient:
Returns:
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
"""
- return await recharge_ads(self, account, amount)
+ return await self.ads.recharge_ads(account, amount)
async def get_wallet(self) -> WalletInfo:
"""Return the address, state, and balances of the wallet.
@@ -204,7 +185,7 @@ class FragmentClient:
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
``ton_balance`` in TON, and ``usdt_balance`` in USDT.
"""
- return await get_wallet_info(self)
+ return await self.tonapi.get_wallet()
async def giveaway_stars(
self,
@@ -225,7 +206,7 @@ class FragmentClient:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
- return await giveaway_stars(self, channel, winners, amount, payment_method)
+ return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method)
async def giveaway_premium(
self,
@@ -246,7 +227,7 @@ class FragmentClient:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
- return await giveaway_premium(self, channel, winners, months, payment_method)
+ return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method)
async def get_login_code(self, number: str) -> LoginCodeResult:
"""Fetch the current pending login code for an anonymous number.
@@ -258,7 +239,7 @@ class FragmentClient:
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
and ``active_sessions`` count.
"""
- return await get_login_code(self, number)
+ return await self.anonymous_numbers.get_login_code(number)
async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
"""Enable or disable login code delivery for an anonymous number.
@@ -267,7 +248,7 @@ class FragmentClient:
number: Phone number with or without leading ``+``.
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
"""
- return await toggle_login_codes(self, number, can_receive)
+ return await self.anonymous_numbers.toggle_login_codes(number, can_receive)
async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
"""Terminate all active Telegram sessions for an anonymous number.
@@ -281,7 +262,7 @@ class FragmentClient:
Raises:
AnonymousNumberError: If the number is not owned by this account or has no active sessions.
"""
- return await terminate_sessions(self, number)
+ return await self.anonymous_numbers.terminate_sessions(number)
async def search_usernames(
self,
@@ -305,7 +286,7 @@ class FragmentClient:
:class:`UsernamesResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
"""
- return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id)
+ return await self.marketplace.search_usernames(query, sort=sort, filter=filter, offset_id=offset_id)
async def search_numbers(
self,
@@ -329,7 +310,7 @@ class FragmentClient:
:class:`NumbersResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
"""
- return await search_numbers(self, query, sort=sort, filter=filter, offset_id=offset_id)
+ return await self.marketplace.search_numbers(query, sort=sort, filter=filter, offset_id=offset_id)
async def search_gifts(
self,
@@ -361,8 +342,8 @@ class FragmentClient:
:class:`GiftsResult` with ``items`` (parsed list of item dicts)
and ``next_offset`` (``None`` on the last page).
"""
- return await search_gifts(
- self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
+ return await self.marketplace.search_gifts(
+ query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
)
async def call(
diff --git a/pyfragment/core/__init__.py b/pyfragment/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pyfragment/types/constants.py b/pyfragment/core/constants.py
similarity index 73%
rename from pyfragment/types/constants.py
rename to pyfragment/core/constants.py
index 029c55d..23a3ef0 100644
--- a/pyfragment/types/constants.py
+++ b/pyfragment/core/constants.py
@@ -1,34 +1,21 @@
from __future__ import annotations
import json
-from typing import Any, Literal
+from typing import Any
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
-# Payment methods
-PaymentMethod = Literal["ton", "usdt_ton"]
-
-# Single source of truth for supported wallet versions
-WalletVersion = Literal["V4R2", "V5R1"]
-
-# Wallet class map — used to resolve the correct contract from WALLET_VERSION
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
-# Minimum TON balance threshold required for payment flows.
MIN_TON_BALANCE: float = 0.33
-
-# USDT (TON) jetton metadata used for payment-method balance checks.
USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
MIN_USDT_BALANCE: float = 0.75
-# Default HTTP request timeout in seconds.
DEFAULT_TIMEOUT: float = 30.0
-# Required Fragment session cookie keys
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
-# Fragment domain and page URLs
-FRAGMENT_DOMAIN: str = "fragment.com" # for rookiepy
+FRAGMENT_DOMAIN: str = "fragment.com"
FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}"
STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway"
@@ -38,7 +25,6 @@ ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts"
-# Browsers supported by get_cookies_from_browser()
SUPPORTED_BROWSERS: frozenset[str] = frozenset(
{
"arc",
@@ -57,7 +43,6 @@ SUPPORTED_BROWSERS: frozenset[str] = frozenset(
}
)
-# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
DEVICE: str = json.dumps(
{
"platform": "iphone",
@@ -72,8 +57,6 @@ DEVICE: str = json.dumps(
}
)
-# Base HTTP headers — shared across all Fragment API requests.
-# Each method merges these with its own "referer" and "x-aj-referer".
BASE_HEADERS: dict[str, str] = {
"accept": "application/json, text/javascript, */*; q=0.01",
"accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7",
diff --git a/pyfragment/utils/cookies.py b/pyfragment/core/cookies.py
similarity index 59%
rename from pyfragment/utils/cookies.py
rename to pyfragment/core/cookies.py
index 7f9aaa4..85c568d 100644
--- a/pyfragment/utils/cookies.py
+++ b/pyfragment/core/cookies.py
@@ -1,41 +1,31 @@
from __future__ import annotations
+import importlib
from datetime import datetime, timezone
from typing import Any
-import rookiepy
+from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
+from pyfragment.exceptions import CookieError
+from pyfragment.models.cookies import CookieResult
-from pyfragment.types import CookieError
-from pyfragment.types import CookieResult as CookieResult
-from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
+try:
+ import rookiepy # type: ignore[import-not-found]
+except Exception:
+ rookiepy = None
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
- """Extract Fragment session cookies directly from an installed browser.
+ global rookiepy
- Reads the browser's on-disk cookie store (no extension required) and
- returns the four cookies required by :class:`~pyfragment.FragmentClient`
- along with the session expiry timestamp.
-
- Args:
- browser: Browser name to read cookies from — case-insensitive. Supported values:
- ``"chrome"`` (default), ``"firefox"``, ``"edge"``, ``"brave"``, ``"arc"``,
- ``"opera"``, ``"opera_gx"``, ``"chromium"``, ``"chromium_based"``,
- ``"firefox_based"``, ``"vivaldi"``, ``"librewolf"``, ``"safari"``.
-
- Returns:
- :class:`CookieResult` with ``.cookies`` (dict) and ``.expires`` (ISO 8601 string or ``None``).
-
- Raises:
- CookieError: If the browser is not supported, cookies cannot be read,
- or required keys are missing.
- """
key = browser.lower()
if key not in SUPPORTED_BROWSERS:
supported = ", ".join(sorted(SUPPORTED_BROWSERS))
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
try:
+ if rookiepy is None:
+ rookiepy = importlib.import_module("rookiepy")
+
jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
except Exception as exc:
raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc
@@ -66,7 +56,4 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
if expires_dt < datetime.now(timezone.utc):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
- return CookieResult(
- cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
- expires=expires_iso,
- )
+ return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso)
diff --git a/pyfragment/utils/api.py b/pyfragment/core/transport.py
similarity index 55%
rename from pyfragment/utils/api.py
rename to pyfragment/core/transport.py
index da00f82..9ebeb32 100644
--- a/pyfragment/utils/api.py
+++ b/pyfragment/core/transport.py
@@ -7,8 +7,8 @@ from typing import Any, cast
import httpx
-from pyfragment.types import FragmentPageError, ParseError, VerificationError
-from pyfragment.types.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
+from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
+from pyfragment.exceptions import FragmentPageError, ParseError, VerificationError
async def get_fragment_hash(
@@ -17,25 +17,6 @@ async def get_fragment_hash(
page_url: str,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
- """Fetch the API hash from a Fragment page.
-
- Fragment embeds a short-lived hash in each page's HTML that must be
- included in every subsequent API request. This function loads the page
- as a real browser navigation (not XHR) so Fragment returns full HTML.
-
- Args:
- cookies: Active Fragment session cookies.
- headers: Base headers for the relevant Fragment page.
- page_url: URL of the Fragment page to fetch the hash from.
- timeout: HTTP request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``.
-
- Returns:
- Lowercase hex hash string.
-
- Raises:
- FragmentPageError: If the page returns a non-200 status or the hash
- is not found in the response HTML.
- """
page_headers = {
k: v
for k, v in headers.items()
@@ -65,18 +46,6 @@ async def get_fragment_hash(
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
- """Parse a Fragment API JSON response.
-
- Args:
- response: The HTTP response object.
- context: Human-readable name of the API method, used in error messages.
-
- Returns:
- Parsed response as a dict.
-
- Raises:
- ParseError: If the response body cannot be decoded as JSON.
- """
try:
return cast(dict[str, Any], response.json())
except Exception as exc:
@@ -89,21 +58,6 @@ async def fragment_request(
headers: dict[str, str],
data: dict[str, Any],
) -> dict[str, Any]:
- """POST a single request to the Fragment API.
-
- Builds the ``/api?hash=`` URL, sends the request, and returns the
- parsed JSON body. Use this for every API method call — search,
- init, state updates, etc.
-
- Args:
- session: Active httpx session with Fragment cookies.
- fragment_hash: Short-lived hash from the Fragment page HTML.
- headers: Page-specific HTTP headers.
- data: Form data payload; must include a ``"method"`` key.
-
- Returns:
- Parsed API response as a dict.
- """
for attempt in range(3):
resp = await session.post(
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
@@ -127,21 +81,6 @@ async def execute_transaction_request(
tx_data: dict[str, Any],
fragment_hash: str,
) -> dict[str, Any]:
- """Post a transaction request to the Fragment API.
-
- Args:
- session: Active httpx session with Fragment cookies.
- headers: Page-specific HTTP headers.
- tx_data: Form data payload for the API method.
- fragment_hash: Short-lived hash from the Fragment page.
-
- Returns:
- Parsed API response dict containing transaction data.
-
- Raises:
- VerificationError: If Fragment requires KYC verification.
- ParseError: If the response cannot be parsed.
- """
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
if transaction.get("need_verify"):
diff --git a/pyfragment/domains/__init__.py b/pyfragment/domains/__init__.py
new file mode 100644
index 0000000..8611980
--- /dev/null
+++ b/pyfragment/domains/__init__.py
@@ -0,0 +1 @@
+"""Domain-level helpers for Fragment operations."""
diff --git a/pyfragment/domains/ads/__init__.py b/pyfragment/domains/ads/__init__.py
new file mode 100644
index 0000000..50fd95a
--- /dev/null
+++ b/pyfragment/domains/ads/__init__.py
@@ -0,0 +1,5 @@
+from pyfragment.domains.ads.recharge import recharge_ads
+from pyfragment.domains.ads.service import AdsService
+from pyfragment.domains.ads.tonup import topup_ton
+
+__all__ = ["AdsService", "recharge_ads", "topup_ton"]
diff --git a/pyfragment/methods/recharge_ads.py b/pyfragment/domains/ads/recharge.py
similarity index 61%
rename from pyfragment/methods/recharge_ads.py
rename to pyfragment/domains/ads/recharge.py
index 19cf098..5c6285e 100644
--- a/pyfragment/methods/recharge_ads.py
+++ b/pyfragment/domains/ads/recharge.py
@@ -3,38 +3,17 @@ from __future__ import annotations
import json
from typing import TYPE_CHECKING
-from pyfragment.types import (
- AdsRechargeResult,
- ConfigurationError,
- FragmentAPIError,
- FragmentError,
- UnexpectedError,
- VerificationError,
-)
-from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
-from pyfragment.utils import get_account_info, process_transaction
+from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
+from pyfragment.domains.tonapi.account import get_account_info
+from pyfragment.domains.tonapi.transaction import process_transaction
+from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
+from pyfragment.models.payments import AdsRechargeResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
- """Add funds to your own Telegram Ads account.
-
- Args:
- client: Authenticated :class:`FragmentClient` instance.
- account: Your Fragment Ads account identifier — the channel or bot username
- the Ads account is linked to (e.g. ``"@mychannel"``).
- amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
-
- Returns:
- :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
-
- Raises:
- ConfigurationError: If ``amount`` is not a valid integer in the allowed range.
- FragmentAPIError: If the Fragment API returns an error.
- UnexpectedError: For any other unexpected failure.
- """
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
diff --git a/pyfragment/domains/ads/service.py b/pyfragment/domains/ads/service.py
new file mode 100644
index 0000000..ce310cc
--- /dev/null
+++ b/pyfragment/domains/ads/service.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from pyfragment.domains.ads.recharge import recharge_ads
+from pyfragment.domains.ads.tonup import topup_ton
+from pyfragment.domains.base import BaseService
+from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult
+
+if TYPE_CHECKING:
+ pass
+
+
+class AdsService(BaseService):
+ async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
+ return await recharge_ads(self._client, account, amount)
+
+ async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
+ return await topup_ton(self._client, username, amount, show_sender=show_sender)
diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/domains/ads/tonup.py
similarity index 66%
rename from pyfragment/methods/topup_ton.py
rename to pyfragment/domains/ads/tonup.py
index d3ad869..c86a77c 100644
--- a/pyfragment/methods/topup_ton.py
+++ b/pyfragment/domains/ads/tonup.py
@@ -3,8 +3,11 @@ from __future__ import annotations
import json
from typing import TYPE_CHECKING
-from pyfragment.types import (
- AdsTopupResult,
+from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
+from pyfragment.domains.payments import parse_required_payment_amount
+from pyfragment.domains.tonapi.account import get_account_info
+from pyfragment.domains.tonapi.transaction import process_transaction
+from pyfragment.exceptions import (
ConfigurationError,
FragmentAPIError,
FragmentError,
@@ -12,31 +15,13 @@ from pyfragment.types import (
UserNotFoundError,
VerificationError,
)
-from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
-from pyfragment.utils import get_account_info, process_transaction
+from pyfragment.models.payments import AdsTopupResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
- """Top up TON to a recipient's Telegram balance.
-
- Args:
- client: Authenticated :class:`FragmentClient` instance.
- username: Recipient's Telegram username (with or without ``@``).
- amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
- show_sender: Show your name as the sender. Defaults to ``True``.
-
- Returns:
- :class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
-
- Raises:
- ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000.
- UserNotFoundError: If the recipient is not found on Telegram.
- FragmentAPIError: If the Fragment API returns an error.
- UnexpectedError: For any other unexpected failure.
- """
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
@@ -49,6 +34,7 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE)
+ required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
@@ -68,7 +54,7 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
- tx_hash = await process_transaction(client, transaction)
+ tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount)
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError:
diff --git a/pyfragment/domains/anonymous_numbers/__init__.py b/pyfragment/domains/anonymous_numbers/__init__.py
new file mode 100644
index 0000000..9b7bb67
--- /dev/null
+++ b/pyfragment/domains/anonymous_numbers/__init__.py
@@ -0,0 +1,12 @@
+from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes
+from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
+from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
+
+__all__ = [
+ "AnonymousNumbersService",
+ "LoginCodeResult",
+ "TerminateSessionsResult",
+ "get_login_code",
+ "terminate_sessions",
+ "toggle_login_codes",
+]
diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/domains/anonymous_numbers/number.py
similarity index 59%
rename from pyfragment/methods/anonymous_number.py
rename to pyfragment/domains/anonymous_numbers/number.py
index fc79d29..9a84c48 100644
--- a/pyfragment/methods/anonymous_number.py
+++ b/pyfragment/domains/anonymous_numbers/number.py
@@ -3,16 +3,10 @@ from __future__ import annotations
import html
from typing import TYPE_CHECKING
-from pyfragment.types import (
- AnonymousNumberError,
- FragmentAPIError,
- FragmentError,
- LoginCodeResult,
- TerminateSessionsResult,
- UnexpectedError,
-)
-from pyfragment.types.constants import NUMBERS_PAGE
-from pyfragment.utils import parse_login_code
+from pyfragment.core.constants import NUMBERS_PAGE
+from pyfragment.domains.anonymous_numbers.parser import parse_login_code
+from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError
+from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -23,20 +17,6 @@ def _strip_plus(number: str) -> str:
async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult:
- """Fetch the current pending login code for an anonymous number.
-
- Args:
- client: Authenticated :class:`FragmentClient` instance.
- number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
-
- Returns:
- :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if no pending code),
- and ``active_sessions`` count.
-
- Raises:
- FragmentAPIError: If the Fragment API returns an error.
- UnexpectedError: For any other unexpected failure.
- """
try:
clean = _strip_plus(number)
result = await client.call(
@@ -59,17 +39,6 @@ async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult
async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None:
- """Enable or disable login code delivery for an anonymous number.
-
- Args:
- client: Authenticated :class:`FragmentClient` instance.
- number: Phone number with or without leading ``+``.
- can_receive: ``True`` to allow receiving codes, ``False`` to block them.
-
- Raises:
- FragmentAPIError: If the Fragment API returns an error.
- UnexpectedError: For any other unexpected failure.
- """
try:
clean = _strip_plus(number)
result = await client.call(
@@ -88,24 +57,6 @@ async def toggle_login_codes(client: FragmentClient, number: str, can_receive: b
async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult:
- """Terminate all active Telegram sessions for an anonymous number.
-
- This is a two-step operation: Fragment first returns a confirmation hash,
- which is then submitted to confirm the termination.
-
- Args:
- client: Authenticated :class:`FragmentClient` instance.
- number: Phone number with or without leading ``+``.
-
- Returns:
- :class:`TerminateSessionsResult` with ``number`` and ``message``.
-
- Raises:
- AnonymousNumberError: If the number is not owned by this account or has no active sessions,
- or if Fragment returns an error during termination.
- FragmentAPIError: If the Fragment API returns an error.
- UnexpectedError: For any other unexpected failure.
- """
try:
clean = _strip_plus(number)
diff --git a/pyfragment/domains/anonymous_numbers/parser.py b/pyfragment/domains/anonymous_numbers/parser.py
new file mode 100644
index 0000000..d7dacee
--- /dev/null
+++ b/pyfragment/domains/anonymous_numbers/parser.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+import re
+
+CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
+ROW_RE = re.compile(r"
]")
+
+
+def parse_login_code(html: str) -> tuple[str | None, int]:
+ match = CODE_RE.search(html)
+ code = match.group(1).strip() if match else None
+ active_sessions = len(ROW_RE.findall(html))
+ return code, active_sessions
diff --git a/pyfragment/domains/anonymous_numbers/service.py b/pyfragment/domains/anonymous_numbers/service.py
new file mode 100644
index 0000000..28c7ec5
--- /dev/null
+++ b/pyfragment/domains/anonymous_numbers/service.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes
+from pyfragment.domains.base import BaseService
+from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
+
+if TYPE_CHECKING:
+ pass
+
+
+class AnonymousNumbersService(BaseService):
+ async def get_login_code(self, number: str) -> LoginCodeResult:
+ return await get_login_code(self._client, number)
+
+ async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
+ return await toggle_login_codes(self._client, number, can_receive)
+
+ async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
+ return await terminate_sessions(self._client, number)
diff --git a/pyfragment/domains/base.py b/pyfragment/domains/base.py
new file mode 100644
index 0000000..01d5ca5
--- /dev/null
+++ b/pyfragment/domains/base.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from pyfragment.client import FragmentClient
+
+
+class BaseService:
+ def __init__(self, client: FragmentClient) -> None:
+ self._client = client
diff --git a/pyfragment/domains/giveaways/__init__.py b/pyfragment/domains/giveaways/__init__.py
new file mode 100644
index 0000000..eaec868
--- /dev/null
+++ b/pyfragment/domains/giveaways/__init__.py
@@ -0,0 +1,11 @@
+from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
+from pyfragment.domains.giveaways.service import GiveawaysService
+from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
+
+__all__ = [
+ "GiveawaysService",
+ "PremiumGiveawayResult",
+ "StarsGiveawayResult",
+ "giveaway_premium",
+ "giveaway_stars",
+]
diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py
new file mode 100644
index 0000000..ab1af30
--- /dev/null
+++ b/pyfragment/domains/giveaways/giveaway.py
@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+import json
+from typing import TYPE_CHECKING, get_args
+
+from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
+from pyfragment.domains.payments import parse_required_payment_amount
+from pyfragment.domains.tonapi.account import get_account_info
+from pyfragment.domains.tonapi.transaction import process_transaction
+from pyfragment.exceptions import (
+ ConfigurationError,
+ FragmentAPIError,
+ FragmentError,
+ UnexpectedError,
+ UserNotFoundError,
+ VerificationError,
+)
+from pyfragment.models.enums import PaymentMethod
+from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
+
+if TYPE_CHECKING:
+ from pyfragment.client import FragmentClient
+
+
+async def giveaway_stars(
+ client: FragmentClient,
+ channel: str,
+ winners: int,
+ amount: int,
+ payment_method: PaymentMethod = "ton",
+) -> StarsGiveawayResult:
+ if not isinstance(winners, int) or not (1 <= winners <= 5):
+ raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
+ if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
+ raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
+ if payment_method not in get_args(PaymentMethod):
+ raise ConfigurationError(
+ ConfigurationError.INVALID_PAYMENT_METHOD.format(
+ method=payment_method,
+ supported=", ".join(sorted(get_args(PaymentMethod))),
+ )
+ )
+
+ try:
+ result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
+ recipient = result.get("found", {}).get("recipient")
+ if not recipient:
+ raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
+
+ result = await client.call(
+ "initGiveawayStarsRequest",
+ {
+ "recipient": recipient,
+ "quantity": str(winners),
+ "stars": str(amount),
+ "payment_method": payment_method,
+ },
+ page_url=STARS_GIVEAWAY_PAGE,
+ )
+ required_payment_amount = parse_required_payment_amount(result)
+ req_id = result.get("req_id")
+ if not req_id:
+ raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
+
+ account = await get_account_info(client)
+ transaction = await client.call(
+ "getGiveawayStarsLink",
+ {
+ "account": json.dumps(account),
+ "device": DEVICE,
+ "transaction": 1,
+ "id": req_id,
+ },
+ page_url=STARS_GIVEAWAY_PAGE,
+ )
+ if transaction.get("need_verify"):
+ raise VerificationError(VerificationError.KYC_REQUIRED)
+
+ tx_hash = await process_transaction(
+ client,
+ transaction,
+ payment_method=payment_method,
+ required_payment_amount=required_payment_amount,
+ )
+ return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount)
+
+ except FragmentError:
+ raise
+ except Exception as exc:
+ raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+
+
+async def giveaway_premium(
+ client: FragmentClient,
+ channel: str,
+ winners: int,
+ months: int = 3,
+ payment_method: PaymentMethod = "ton",
+) -> PremiumGiveawayResult:
+ if not isinstance(winners, int) or not (1 <= winners <= 24_000):
+ raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
+ if months not in (3, 6, 12):
+ raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
+ if payment_method not in get_args(PaymentMethod):
+ raise ConfigurationError(
+ ConfigurationError.INVALID_PAYMENT_METHOD.format(
+ method=payment_method,
+ supported=", ".join(sorted(get_args(PaymentMethod))),
+ )
+ )
+
+ try:
+ result = await client.call(
+ "searchPremiumGiveawayRecipient",
+ {"query": channel, "quantity": winners, "months": months},
+ page_url=PREMIUM_GIVEAWAY_PAGE,
+ )
+ recipient = result.get("found", {}).get("recipient")
+ if not recipient:
+ raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
+
+ result = await client.call(
+ "initGiveawayPremiumRequest",
+ {
+ "recipient": recipient,
+ "quantity": str(winners),
+ "months": str(months),
+ "payment_method": payment_method,
+ },
+ page_url=PREMIUM_GIVEAWAY_PAGE,
+ )
+ required_payment_amount = parse_required_payment_amount(result)
+ req_id = result.get("req_id")
+ if not req_id:
+ raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
+
+ account = await get_account_info(client)
+ transaction = await client.call(
+ "getGiveawayPremiumLink",
+ {
+ "account": json.dumps(account),
+ "device": DEVICE,
+ "transaction": 1,
+ "id": req_id,
+ },
+ page_url=PREMIUM_GIVEAWAY_PAGE,
+ )
+ if transaction.get("need_verify"):
+ raise VerificationError(VerificationError.KYC_REQUIRED)
+
+ tx_hash = await process_transaction(
+ client,
+ transaction,
+ payment_method=payment_method,
+ required_payment_amount=required_payment_amount,
+ )
+ return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months)
+
+ except FragmentError:
+ raise
+ except Exception as exc:
+ raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
diff --git a/pyfragment/domains/giveaways/service.py b/pyfragment/domains/giveaways/service.py
new file mode 100644
index 0000000..abbaefa
--- /dev/null
+++ b/pyfragment/domains/giveaways/service.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from pyfragment.domains.base import BaseService
+from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
+from pyfragment.models.enums import PaymentMethod
+from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
+
+if TYPE_CHECKING:
+ pass
+
+
+class GiveawaysService(BaseService):
+ async def giveaway_stars(
+ self,
+ channel: str,
+ winners: int,
+ amount: int,
+ payment_method: PaymentMethod = "ton",
+ ) -> StarsGiveawayResult:
+ return await giveaway_stars(self._client, channel, winners, amount, payment_method=payment_method)
+
+ async def giveaway_premium(
+ self,
+ channel: str,
+ winners: int,
+ months: int = 3,
+ payment_method: PaymentMethod = "ton",
+ ) -> PremiumGiveawayResult:
+ return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method)
diff --git a/pyfragment/domains/marketplace/__init__.py b/pyfragment/domains/marketplace/__init__.py
new file mode 100644
index 0000000..ed298ed
--- /dev/null
+++ b/pyfragment/domains/marketplace/__init__.py
@@ -0,0 +1,13 @@
+from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
+from pyfragment.domains.marketplace.service import MarketplaceService
+from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
+
+__all__ = [
+ "GiftsResult",
+ "MarketplaceService",
+ "NumbersResult",
+ "UsernamesResult",
+ "search_gifts",
+ "search_numbers",
+ "search_usernames",
+]
diff --git a/pyfragment/domains/marketplace/parser.py b/pyfragment/domains/marketplace/parser.py
new file mode 100644
index 0000000..0bd681d
--- /dev/null
+++ b/pyfragment/domains/marketplace/parser.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+ROW_BLOCK_RE = re.compile(r'
]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)
', re.DOTALL)
+HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"')
+VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<')
+PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<")
+DATETIME_RE = re.compile(r'