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