26 Commits

Author SHA1 Message Date
Bohdan 4222799298 Merge pull request #35 from bohd4nx/dev
Dev
2026-07-10 23:51:25 +01:00
bohd4nx 27ce28fd32 fix: correct regex pattern in get_fragment_hash function to properly escape backslashes 2026-07-06 11:27:10 +03:00
bohd4nx b11b9f299b fix: correct regex pattern in get_fragment_hash function to properly match fragment hash 2026-07-05 16:47:26 +03:00
bohd4nx 0a361c8c3a chore: add CONTRIBUTING and SECURITY guidelines to improve project documentation 2026-07-05 16:25:23 +03:00
bohd4nx f46551c905 Refactor: remove httpx from mypy overrides in pyproject.toml 2026-07-05 16:18:40 +03:00
bohd4nx 2bd8cc177a chore: update version to 2026.3.3 and dependencies; improve string formatting in transport functions 2026-07-05 16:16:14 +03:00
bohd4nx fd31342d03 Refactor: Replace httpx with curl-cffi for HTTP requests and update dependencies 2026-07-05 16:13:16 +03:00
Bohdan bdaf4a960d Merge pull request #30 from bohd4nx/dev
Dev
2026-06-27 03:05:52 +03:00
bohd4nx d78be012a3 chore: remove author credit from README footer 2026-06-27 02:46:41 +03:00
bohd4nx ef831d12a0 chore: update Python version to 3.11 in CI and project files; remove outdated CONTRIBUTING and SECURITY documents 2026-06-27 02:25:09 +03:00
Bohdan 👾 380fe40ffd Merge pull request #28 from bohd4nx/dev
Dev
2026-06-16 01:50:14 +03:00
bohd4nx 4539dc9803 Refactor tests: Add error handling tests for API provider and recharge flows 2026-06-16 01:49:11 +03:00
bohd4nx a9663d0b08 chore: update setup-uv action to v8.2.0 and update changelog date 2026-06-16 01:41:03 +03:00
bohd4nx 228fbfcd1c Refactor tonapi module: Move account and transaction logic to services
- Moved account-related functions and classes from `pyfragment.domains.tonapi.account` to `pyfragment.services.tonapi.account`.
- Moved transaction-related functions and classes from `pyfragment.domains.tonapi.transaction` to `pyfragment.services.tonapi.transaction`.
- Updated imports across the codebase to reflect the new structure.
- Removed unused `tonapi` module files and cleaned up related code.
- Introduced `ApiProvider` enum to manage API provider types.
- Added validation functions for cookies and wallet versions in a new `validation.py` module.
2026-06-16 01:37:26 +03:00
bohd4nx dc9661134d Refactor cookie handling and service structure
- Removed the old CookieResult model from pyfragment.core.models and created a new one in pyfragment.services.cookies.models.
- Implemented get_cookies_from_browser function in pyfragment.services.cookies.service to extract cookies directly from supported browsers.
- Updated imports across various modules to reflect the new structure and removed unused imports.
- Cleaned up models in ads, anonymous_numbers, giveaways, marketplace, and purchases domains by removing unnecessary __all__ declarations.
- Added new example scripts for cookie extraction and various purchase functionalities.
- Consolidated constants into a new pyfragment.core.constants module for better organization.
2026-06-16 01:19:33 +03:00
bohd4nx 9f11ccb3ee Refactor pyfragment structure: Move models to domain-specific modules
- Moved PaymentMethod and other enums from models to enums.py for better organization.
- Refactored imports in various files to reflect the new structure.
- Created new model files for ads, anonymous numbers, giveaways, marketplace, purchases, and tonapi.
- Removed the old models.py file to clean up the codebase.
- Updated all relevant imports in the codebase to use the new model locations.
2026-06-16 01:02:28 +03:00
bohd4nx 8f865f4911 Refactor for GRAM (ex TON) integration
- Updated imports and references from TON to GRAM across multiple modules.
- Renamed functions and variables to reflect the change from TON to GRAM.
- Adjusted validation checks for top-up and recharge amounts to use GRAM limits.
- Modified error messages and exceptions to indicate GRAM instead of TON.
- Updated tests to ensure they reflect the new GRAM terminology and functionality.
- Introduced new constants for GRAM-related configurations.
2026-06-16 00:47:49 +03:00
bohd4nx 3481769757 feat: add Highload wallet versions and update mypy exclusion patterns 2026-06-05 02:04:44 +03:00
bohd4nx b85b34f2b1 feat: update changelog for new payment methods; add UserNotFoundError handling in purchase flows 2026-05-30 00:17:06 +03:00
bohd4nx d01c45d85b feat: update version to 2026.3.2; enhance changelog with new payment methods and updated client documentation 2026-05-29 23:16:19 +03:00
bohd4nx 695430744a feat: update payment method options and validation across purchase and giveaway examples; add AlreadySubscribedError exception handling 2026-05-29 23:14:59 +03:00
bohd4nx afaa42776b feat: update payment method handling and validation across giveaway examples; adjust limits for winners and amounts 2026-05-29 22:59:41 +03:00
bohd4nx ed54d1f1a0 feat: enhance FragmentClient to support custom headers; update limits for TON topup and stars purchases 2026-05-29 22:51:27 +03:00
bohd4nx 6aa7037380 feat: update payment method defaults to use PaymentMethod enum; refactor related validation checks 2026-05-29 22:31:30 +03:00
bohd4nx 5124af17ef feat: reorganize constants into separate modules; update validation logic for mnemonic and API key lengths 2026-05-29 22:22:09 +03:00
bohd4nx 66bcd22198 feat: update constants and validation for stars and payment methods; refactor enums for clarity 2026-05-29 22:11:03 +03:00
74 changed files with 1352 additions and 756 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: "3.10"
python-version: "3.11"
cache: pip
- run: pip install ".[dev]"
@@ -30,7 +30,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v8.1.0
- uses: astral-sh/setup-uv@v8.2.0
- run: uv build
+4
View File
@@ -37,3 +37,7 @@ systests/
dist/
build/
*.egg-info/
uv.lock
#docs
*-docs/
+53
View File
@@ -7,6 +7,57 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
---
## [2026.3.2] — 2026-06-16
### Added
- Added `ApiProvider` enum with `TONAPI` (tonconsole.com, default) and `TONCENTER` (t.me/toncenter) values.
- Added `api_provider` parameter to `FragmentClient` — select the blockchain API provider at init time (`"tonapi"` or `"toncenter"`).
- Both providers accept `api_key` with the same interface; the correct `tonutils` client is selected automatically.
- New `AlreadySubscribedError` exception for Premium purchase flows when Fragment returns: `This account is already subscribed to Telegram Premium.`
- New `UserNotFoundError.NOT_A_USER` message for when Fragment returns: `Please enter a username assigned to a user.` (e.g. when the username belongs to a channel or bot).
- Added `WalletVersion.HighloadV2` and `WalletVersion.HighloadV3R1` to `WalletVersion`
### Changed
- Updated purchase and giveaway flow state nonces (`dh`) to use nonce-like dynamic values with a wider integer range.
- Stars and Premium giveaway flows now include explicit price update steps before init requests:
- `updateStarsGiveawayPrices`
- `updatePremiumGiveawayPrices`
- Updated `DEVICE_INFO` fingerprint: Tonkeeper `appVersion` -> `26.05.0`.
- Updated client docstrings and purchase examples to document all supported payment methods.
### Renamed — TON -> GRAM (ex TON)
The TON blockchain has been rebranded to **GRAM (ex TON)**. All identifiers, messages, and documentation have been updated accordingly.
**Public API**
- `FragmentClient.topup_ton()``topup_gram()`
- `PaymentMethod.TON``PaymentMethod.GRAM`
- `PaymentMethod.USDT_TON``PaymentMethod.USDT_GRAM`
- `WalletInfo.ton_balance``WalletInfo.gram_balance`
**Constants**
- `TON_TOPUP_MIN` / `TON_TOPUP_MAX``GRAM_TOPUP_MIN` / `GRAM_TOPUP_MAX`
- `MIN_TON_BALANCE``MIN_GRAM_BALANCE`
- `USDT_TON_MASTER_ADDRESS``USDT_GRAM_MASTER_ADDRESS`
**Exceptions**
- `ConfigurationError.INVALID_TON_AMOUNT``INVALID_GRAM_AMOUNT`
- `WalletError.LOW_TON_BALANCE``LOW_GRAM_BALANCE`
- `WalletError.TON_BALANCE_CHECK_FAILED``GRAM_BALANCE_CHECK_FAILED`
**Internals**
- `pyfragment/core/constants/ton.py``gram.py`
- `check_ton_payment_balance()``check_gram_payment_balance()`
---
## [2026.3.1] — 2026-05-29
### Added
@@ -220,6 +271,8 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
- `py.typed` marker — full PEP 561 typing support for type-checkers
- `__repr__` on all result types for readable debug output
[2026.3.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.2
[2026.3.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.1
[2026.3.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.0
[2026.2.3]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.3
[2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2
+10 -8
View File
@@ -28,16 +28,18 @@ All three must pass before opening a PR.
```
pyfragment/
client.py — FragmentClient (public entry point)
core/ transport, cookies, constants
enums.pyApiProvider, PaymentMethod, WalletVersion
exceptions.py — exception hierarchy
core/ — constants, validation helpers
domains/ — one package per feature domain
ads/ — recharge_ads, topup_ton
anonymous_numbers/— buy_number, manage_number
ads/ — recharge_ads, topup_gram
anonymous_numbers/— get_login_code, toggle_login_codes, terminate_sessions
giveaways/ — giveaway_stars, giveaway_premium
marketplace/ — search_usernames, search_numbers, search_gifts
purchases/ — purchase_stars, purchase_premium
tonapi/ — wallet info, transaction signing
models/ result dataclasses and enums
exceptions.pyexception hierarchy
services/ — shared infrastructure services
cookies/browser cookie extraction (models + service)
tonapi/ wallet info, transaction signing (tonapi/toncenter)
tests/ — unit tests (pytest)
examples/ — runnable usage examples (excluded from CI)
```
@@ -45,8 +47,8 @@ examples/ — runnable usage examples (excluded from CI)
## Conventions
- All public async methods live on `FragmentClient` and delegate to a domain service.
- Domain functions receive a `FragmentClient` instance, never raw httpx clients.
- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.domains.tonapi.transaction.process_transaction`.
- Domain functions receive a `FragmentClient` instance, never raw HTTP clients.
- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.services.tonapi.transaction._make_ton_client`.
- Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`.
## Pull requests
+12 -107
View File
@@ -9,18 +9,14 @@
[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=Tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE)
Async Python client for the **[Fragment](https://fragment.com)** marketplace API.
Buy Stars & Premium, run giveaways, top up TON and Ads balances,
manage anonymous numbers, and search Fragment listings.
Async Python client for the **[Fragment.com](https://fragment.com)** marketplace API.
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate TON](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)**
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)**
</div>
> **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
---
## Installation
```bash
@@ -38,123 +34,32 @@ pip install git+https://github.com/bohd4nx/pyfragment.git@dev
```python
import asyncio
from pyfragment import FragmentClient
from pyfragment.enums import PaymentMethod
async def main() -> None:
async with FragmentClient(
seed="word1 word2 ... word24",
api_key="YOUR_TONAPI_KEY",
cookies={
"stel_ssid": "...",
"stel_dt": "...",
"stel_token": "...",
"stel_ton_token": "...",
},
api_key="YOUR_API_KEY",
cookies={"stel_ssid": "...", "stel_dt": "...", "stel_token": "...", "stel_ton_token": "..."},
) as client:
wallet = await client.get_wallet()
print(f"TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
print("GRAM: %s | USDT: %s" % (wallet.gram_balance, wallet.usdt_balance))
recipient = "https://t.me/username" # also: @username, username
stars = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.USDT_GRAM)
print("Sent %s Stars to %s | tx: %s" % (stars.amount, stars.username, stars.transaction_id))
stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton")
print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}")
premium = await client.purchase_premium(recipient, months=6, payment_method="ton")
print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}")
premium = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM)
print("Sent Premium %sm to %s | tx: %s" % (premium.amount, premium.username, premium.transaction_id))
asyncio.run(main())
```
---
## Configuration
| Parameter | Type | Default | Description |
| ---------------- | ------------- | -------- | ----------------------------------------------------------- |
| `seed` | `str` | — | 12- or 24-word TON wallet mnemonic |
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
| `cookies` | `dict \| str` | — | Fragment session cookies |
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
---
## Credentials
### Fragment cookies
Log in to [fragment.com](https://fragment.com) and connect your TON wallet.
**Automatically** (recommended) — reads directly from your browser, no extension needed:
```bash
pip install "pyfragment[browser]"
```
```python
from pyfragment import get_cookies_from_browser
result = get_cookies_from_browser("chrome") # firefox, edge, brave, ...
# result.cookies — dict[str, str] ready to pass to FragmentClient
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
```
**Manually** — use [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass as a `dict` or JSON string.
Refresh cookies when you get authentication errors.
### Tonapi key
Generate at [tonconsole.com](https://tonconsole.com).
### Seed phrase
12- or 24-word mnemonic from your TON wallet (**Tonkeeper → Settings → Backup**). Never share it.
---
## Logging
`pyfragment` uses the standard `logging` module under the `pyfragment` namespace and is silent by default:
```python
import logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("pyfragment").setLevel(logging.DEBUG) # for detailed request logs
```
---
## Error Handling
All exceptions inherit from `FragmentError`:
```python
from pyfragment import (
ConfigurationError, # invalid arguments (amount, months, payment_method…)
UserNotFoundError, # recipient not found on Fragment
WalletError, # insufficient TON or USDT balance
TransactionError, # broadcast failed, duplicate seqno, invalid payload
FragmentAPIError, # Fragment API returned an error response
FragmentPageError, # page fetch or hash extraction failed
AnonymousNumberError, # number not owned, wrong state, login code issues
CookieError, # missing or malformed session cookies
ParseError, # failed to decode Fragment payload
VerificationError, # on-chain verification step failed
OperationError, # generic operation-level failure
UnexpectedError, # unexpected API response structure
)
```
---
<div align="center">
Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
**Star ⭐ if you found it useful**
[Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
</div>
+1 -1
View File
@@ -16,4 +16,4 @@ You will receive a response within 72 hours. Once the fix is released, the advis
## Scope
This library handles sensitive credentials (TON seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity.
This library handles sensitive credentials (GRAM (ex TON) seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity.
+8 -2
View File
@@ -13,7 +13,7 @@ import json
from pyfragment import FragmentClient, GiftsResult
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -33,7 +33,13 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):")
+8 -2
View File
@@ -12,7 +12,7 @@ import json
from pyfragment import FragmentClient, NumbersResult
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -31,7 +31,13 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):")
+8 -2
View File
@@ -12,7 +12,7 @@ import json
from pyfragment import FragmentClient, UsernamesResult
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -31,7 +31,13 @@ FILTER = "auction" # "", "auction", "sale", "sold" — or omit
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):")
+33
View File
@@ -0,0 +1,33 @@
"""
Example: extract Fragment cookies directly from your browser.
get_cookies_from_browser() reads the Fragment session cookies from a locally
installed browser — no manual copy-paste required.
Supported browsers: arc, brave, chrome, chromium, chromium_based, edge,
firefox, firefox_based, librewolf, opera, opera_gx,
safari, vivaldi.
The returned CookieResult.cookies dict can be passed directly to FragmentClient.
"""
from pyfragment import CookieError, get_cookies_from_browser
def main() -> None:
try:
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
except CookieError as e:
print(f"Could not read cookies: {e}")
return
print(f"Cookies expire: {result.expires}")
print(f"Keys found: {list(result.cookies.keys())}")
# Pass the extracted cookies directly to FragmentClient
# async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=result.cookies) as client:
# ...
if __name__ == "__main__":
main()
+8 -2
View File
@@ -14,7 +14,7 @@ import asyncio
from pyfragment import FragmentClient
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -33,7 +33,13 @@ PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragmen
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result = await client.call(METHOD, DATA, page_url=PAGE_URL)
print(result)
+6 -4
View File
@@ -1,8 +1,9 @@
"""
Example: fetch wallet address, state, and separate TON/USDT balances.
Example: fetch wallet address, state, and separate GRAM (ex TON)/USDT balances.
Cookies can be passed as a dict or as a JSON string.
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
api_provider defaults to "tonapi" (tonconsole.com) — pass "toncenter" to use t.me/toncenter instead.
"""
import asyncio
@@ -10,7 +11,7 @@ import asyncio
from pyfragment import FragmentClient
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -29,12 +30,13 @@ async def main() -> None:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2"
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
wallet = await client.get_wallet()
print(f"Address: {wallet.address}")
print(f"State: {wallet.state}")
print(f"Balance: {wallet.ton_balance} TON")
print(f"Balance: {wallet.gram_balance} GRAM (ex TON)")
print(f"Balance: {wallet.usdt_balance} USDT")
+8 -2
View File
@@ -11,7 +11,7 @@ import asyncio
from pyfragment import AnonymousNumberError, FragmentClient
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -28,7 +28,13 @@ NUMBER = "+88888888888"
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
# Fetch the latest login code
result = await client.get_login_code(NUMBER)
if result.code:
@@ -3,16 +3,16 @@ Example: run a Telegram Premium giveaway for a channel.
winners must be an integer between 1 and 24 000.
months (Premium duration per winner) must be 3, 6, or 12.
payment_method can be "ton" or "usdt_ton".
Channel can be "@channel", "channel", or "https://t.me/channel".
"""
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -28,11 +28,17 @@ COOKIES = {
CHANNEL = "https://t.me/channel"
WINNERS = 10 # 124 000
MONTHS = 3 # 3, 6 or 12
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result = await client.giveaway_premium(
CHANNEL,
@@ -3,16 +3,16 @@ Example: purchase Telegram Premium for a user.
Supported durations: 3, 6, or 12 months.
Set show_sender=False to send anonymously.
payment_method can be "ton" or "usdt_ton".
Username can be "@username", "username", or "https://t.me/username".
"""
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -27,11 +27,17 @@ COOKIES = {
USERNAME = "https://t.me/username"
MONTHS = 3 # 3, 6 or 12
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result = await client.purchase_premium(
USERNAME,
@@ -1,18 +1,18 @@
"""
Example: purchase Telegram Stars for a user.
Amount must be an integer between 50 and 1 000 000.
Amount must be an integer between 50 and 10 000 000.
Set show_sender=False to send anonymously.
payment_method can be "ton" or "usdt_ton".
Username can be "@username", "username", or "https://t.me/username".
"""
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -26,12 +26,18 @@ COOKIES = {
}
USERNAME = "https://t.me/username"
AMOUNT = 500 # 501 000 000 stars
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
AMOUNT = 500 # 5010 000 000 stars
PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result = await client.purchase_stars(
USERNAME,
+13 -7
View File
@@ -1,8 +1,8 @@
"""
Example: recharge your own Telegram Ads account with TON.
Example: recharge your own Telegram Ads account with GRAM (ex TON).
Amount must be an integer between 1 and 1 000 000 000 TON.
Your wallet must satisfy the current minimum TON threshold and transaction cost.
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON).
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost.
"""
import asyncio
@@ -15,7 +15,7 @@ from pyfragment import (
)
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -29,11 +29,17 @@ COOKIES = {
}
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
AMOUNT = 10 # 11 000 000 000 TON
AMOUNT = 10 # 11 000 000 000 GRAM (ex TON)
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
except WalletError as e:
@@ -43,7 +49,7 @@ async def main() -> None:
print(f"Invalid argument: {e}")
return
print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
print(f"{result.amount} GRAM (ex TON) recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
if __name__ == "__main__":
@@ -1,18 +1,18 @@
"""
Example: run a Telegram Stars giveaway for a channel.
winners must be an integer between 1 and 5.
winners must be an integer between 1 and 15.
amount (stars per winner) must be an integer between 500 and 1 000 000.
payment_method can be "ton" or "usdt_ton".
Channel can be "@channel", "channel", or "https://t.me/channel".
"""
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -26,13 +26,19 @@ COOKIES = {
}
CHANNEL = "https://t.me/channel"
WINNERS = 3 # 15
WINNERS = 3 # 115
AMOUNT = 1000 # 5001 000 000 stars per winner
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result = await client.giveaway_stars(
CHANNEL,
@@ -1,10 +1,10 @@
"""
Example: top up TON to a recipient's Telegram balance.
Example: top up GRAM (ex TON) to a recipient's Telegram balance.
For adding TON to a Telegram Ads account, use recharge_ads() instead.
For adding GRAM (ex TON) to a Telegram Ads account, use recharge_ads() instead.
Amount must be an integer between 1 and 1 000 000 000 TON.
Your wallet must satisfy the current minimum TON threshold and transaction cost.
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON).
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost.
"""
import asyncio
@@ -17,7 +17,7 @@ from pyfragment import (
)
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -31,13 +31,19 @@ COOKIES = {
}
USERNAME = "@username"
AMOUNT = 10 # 11 000 000 000 TON
AMOUNT = 10 # 11 000 000 000 GRAM (ex TON)
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try:
result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True)
result = await client.topup_gram(USERNAME, amount=AMOUNT, show_sender=True)
except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return
@@ -48,7 +54,7 @@ async def main() -> None:
print(f"Invalid argument: {e}")
return
print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}")
print(f"{result.amount} GRAM (ex TON) successfully topped up for {result.username} | tx: {result.transaction_id}")
if __name__ == "__main__":
+11 -8
View File
@@ -2,8 +2,14 @@ import logging
from importlib.metadata import version
from pyfragment.client import FragmentClient
from pyfragment.core.cookies import get_cookies_from_browser
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
from pyfragment.enums import ApiProvider, PaymentMethod, WalletVersion
from pyfragment.exceptions import (
AlreadySubscribedError,
AnonymousNumberError,
ClientError,
ConfigurationError,
@@ -19,13 +25,8 @@ from pyfragment.exceptions import (
VerificationError,
WalletError,
)
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.models.cookies import CookieResult
from pyfragment.models.enums import PaymentMethod, WalletVersion
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import WalletInfo
from pyfragment.services.cookies import CookieResult, get_cookies_from_browser
from pyfragment.services.tonapi.models import WalletInfo
logging.getLogger("pyfragment").addHandler(logging.NullHandler())
@@ -53,6 +54,7 @@ __all__ = [
"FragmentAPIError",
"FragmentPageError",
"ConfigurationError",
"AlreadySubscribedError",
"UserNotFoundError",
"WalletError",
"VerificationError",
@@ -64,6 +66,7 @@ __all__ = [
"ParseError",
"UnexpectedError",
# literal types
"ApiProvider",
"PaymentMethod",
"WalletVersion",
"get_cookies_from_browser",
+77 -132
View File
@@ -1,23 +1,29 @@
from __future__ import annotations
import json
from typing import Any, cast, get_args
from typing import Any
from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
from pyfragment.core.validation import (
normalize_provider,
normalize_wallet_version,
parse_cookies,
validate_cookie_keys,
validate_credentials,
)
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult
from pyfragment.domains.ads.service import AdsService
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
from pyfragment.domains.base import raw_api_call
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.domains.giveaways.service import GiveawaysService
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.domains.marketplace.service import MarketplaceService
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
from pyfragment.domains.purchases.service import PurchasesService
from pyfragment.domains.tonapi.service import TonapiService
from pyfragment.exceptions import ConfigurationError, CookieError
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.models.enums import PaymentMethod, WalletVersion
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import WalletInfo
from pyfragment.enums import ApiProvider, PaymentMethod, WalletVersion
from pyfragment.services.tonapi.models import WalletInfo
from pyfragment.services.tonapi.service import TonapiService
class FragmentClient:
@@ -29,14 +35,18 @@ class FragmentClient:
connected with Fragment or Telegram.
Args:
seed: 12- or 24-word mnemonic phrase for the TON wallet.
api_key: Tonapi API key — get one at https://tonconsole.com.
seed: 12- or 24-word mnemonic phrase for the GRAM (ex TON) wallet.
api_key: API key for the chosen provider — tonconsole.com (default) or t.me/toncenter.
cookies: Fragment session cookies as a dict or JSON string.
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
api_provider: Blockchain API provider — ``"tonapi"`` (tonconsole.com, default)
or ``"toncenter"`` (t.me/toncenter).
timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
headers: Custom HTTP request headers. If omitted, :data:`BASE_HEADERS` is used.
Raises:
ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
ConfigurationError: If ``seed``, ``api_key``, ``wallet_version``, or ``api_provider``
are missing or invalid.
CookieError: If ``cookies`` cannot be parsed or are missing required keys.
Example::
@@ -51,63 +61,29 @@ class FragmentClient:
print(result.transaction_id)
"""
@staticmethod
def _parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]:
if isinstance(cookies, str):
try:
cookies = json.loads(cookies)
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
return cast(dict[str, Any], cookies)
@staticmethod
def _validate_required(seed: str, api_key: str) -> None:
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
if missing:
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
word_count = len(seed.split())
if word_count not in (12, 18, 24):
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
if len(api_key.strip()) < 68:
raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
@staticmethod
def _validate_cookie_keys(cookies: dict[str, Any]) -> None:
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
if missing_keys:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
@staticmethod
def _normalize_wallet_version(wallet_version: str) -> WalletVersion:
version = wallet_version.strip().upper()
if version not in get_args(WalletVersion):
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_VERSION.format(
version=version, supported=", ".join(sorted(get_args(WalletVersion)))
)
)
return cast(WalletVersion, version)
def __init__(
self,
seed: str,
api_key: str,
cookies: dict[str, Any] | str,
wallet_version: str = "V5R1",
api_provider: str = "tonapi",
timeout: float = DEFAULT_TIMEOUT,
headers: dict[str, str] | None = None,
) -> None:
self._validate_required(seed, api_key)
parsed_cookies = self._parse_cookies(cookies)
self._validate_cookie_keys(parsed_cookies)
version = self._normalize_wallet_version(wallet_version)
validate_credentials(seed, api_key)
provider = normalize_provider(api_provider)
parsed_cookies = parse_cookies(cookies)
validate_cookie_keys(parsed_cookies)
version = normalize_wallet_version(wallet_version)
self.seed: str = seed.strip()
self.api_key: str = api_key.strip()
self.api_provider: ApiProvider = provider
self.cookies: dict[str, Any] = parsed_cookies
self.wallet_version: WalletVersion = version
self.timeout: float = timeout
self.headers: dict[str, str] = headers if headers is not None else BASE_HEADERS
self.marketplace = MarketplaceService(self)
self.purchases = PurchasesService(self)
self.giveaways = GiveawaysService(self)
@@ -122,14 +98,14 @@ class FragmentClient:
pass
def __repr__(self) -> str:
return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)"
return f"FragmentClient(wallet_version='{self.wallet_version}', api_provider='{self.api_provider}', cookies={len(self.cookies)} keys)"
async def purchase_premium(
self,
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumResult:
"""Gift Telegram Premium to a user.
@@ -137,7 +113,7 @@ class FragmentClient:
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
months: Duration — ``3``, ``6``, or ``12``.
show_sender: Show your name as the sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
@@ -149,41 +125,40 @@ class FragmentClient:
username: str,
amount: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsResult:
"""Send Telegram Stars to a user.
Args:
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
amount: Number of stars — integer from ``50`` to ``1 000 000``.
amount: Number of stars — integer from ``50`` to ``10 000 000``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
"""
return await self.purchases.purchase_stars(username, amount, show_sender=show_sender, payment_method=payment_method)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up TON to a recipient's Telegram balance.
async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up GRAM (ex TON) to a recipient's Telegram balance.
Args:
username: Recipient's Telegram username (with or without ``@``).
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``.
show_sender: Show your name as the sender. Defaults to ``True``.
Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
"""
return await self.ads.topup_ton(username, amount, show_sender=show_sender)
return await self.ads.topup_gram(username, amount, show_sender=show_sender)
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account.
Args:
account: Your Fragment Ads account identifier — the channel or bot username
the Ads account is linked to (e.g. ``"@mychannel"``).
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
account: Channel or bot username the Ads account is linked to (e.g. ``"@mychannel"``).
amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``.
Returns:
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
@@ -194,9 +169,7 @@ class FragmentClient:
"""Return the address, state, and balances of the wallet.
Returns:
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
``ton_balance`` in TON, and ``usdt_balance`` in USDT.
:class:`WalletInfo` with ``address``, ``state``, ``gram_balance``, and ``usdt_balance``.
"""
return await self.tonapi.get_wallet()
@@ -205,19 +178,18 @@ class FragmentClient:
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel.
Args:
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``5``.
winners: Number of winners — integer from ``1`` to ``15``.
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``.
"""
return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method)
@@ -226,19 +198,18 @@ class FragmentClient:
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel.
Args:
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — positive integer.
winners: Number of winners — integer from ``1`` to ``24 000``.
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``.
"""
return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method)
@@ -246,7 +217,7 @@ class FragmentClient:
"""Fetch the current pending login code for an anonymous number.
Args:
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
number: Phone number with or without leading ``+``.
Returns:
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
@@ -273,7 +244,7 @@ class FragmentClient:
:class:`TerminateSessionsResult` with ``number`` and ``message``.
Raises:
AnonymousNumberError: If the number is not owned by this account or has no active sessions.
AnonymousNumberError: If the number is not owned or has no active sessions.
"""
return await self.anonymous_numbers.terminate_sessions(number)
@@ -287,17 +258,13 @@ class FragmentClient:
"""Search the Fragment marketplace for Telegram usernames.
Args:
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id`
from a previous result to fetch the next page.
query: Search text. Omit or pass ``""`` to browse all.
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
offset_id: Pass :attr:`UsernamesResult.next_offset_id` to fetch the next page.
Returns:
:class:`UsernamesResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
:class:`UsernamesResult` with ``items`` and ``next_offset_id``.
"""
return await self.marketplace.search_usernames(query, sort=sort, filter=filter, offset_id=offset_id)
@@ -311,17 +278,13 @@ class FragmentClient:
"""Search the Fragment marketplace for anonymous Telegram numbers.
Args:
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id`
from a previous result to fetch the next page.
query: Search text. Omit or pass ``""`` to browse all.
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
offset_id: Pass :attr:`NumbersResult.next_offset_id` to fetch the next page.
Returns:
:class:`NumbersResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
:class:`NumbersResult` with ``items`` and ``next_offset_id``.
"""
return await self.marketplace.search_numbers(query, sort=sort, filter=filter, offset_id=offset_id)
@@ -338,22 +301,16 @@ class FragmentClient:
"""Search the Fragment gifts marketplace.
Args:
query: Search text. Omit or pass ``""`` to browse without filtering by name.
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
attr: Attribute filters — mapping of trait name to accepted values, e.g.
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
Each key is sent as ``attr[Key]`` with its list of values.
offset: Integer page offset from a previous :class:`GiftsResult`.
Pass ``next_offset`` to fetch the next page.
query: Search text. Omit or pass ``""`` to browse all.
collection: Gift collection slug (e.g. ``"artisanbrick"``).
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
view: Active attribute tab name (e.g. ``"Model"``).
attr: Attribute filters — e.g. ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}``.
offset: Pass :attr:`GiftsResult.next_offset` to fetch the next page.
Returns:
:class:`GiftsResult` with ``items`` (parsed list of item dicts)
and ``next_offset`` (``None`` on the last page).
:class:`GiftsResult` with ``items`` and ``next_offset``.
"""
return await self.marketplace.search_gifts(
query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
@@ -364,24 +321,12 @@ class FragmentClient:
) -> dict[str, Any]:
"""Send a raw request to the Fragment API.
Useful for accessing undocumented or future Fragment API methods
without waiting for a library update.
Args:
method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``.
data: Additional form-data fields to include in the request body.
page_url: Fragment page URL used to derive the API hash and headers.
Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``).
data: Additional form-data fields.
page_url: Fragment page URL to derive the API hash. Defaults to ``FRAGMENT_BASE_URL``.
Returns:
Raw parsed JSON response as a dict.
Example::
result = await client.call(
"searchPremiumGiftRecipient",
{"query": "@username", "months": 3},
page_url="https://fragment.com/premium/gift",
)
"""
return await raw_api_call(self.cookies, self.timeout, method, data, page_url)
return await raw_api_call(self.cookies, self.timeout, method, data, page_url, self.headers)
+52 -46
View File
@@ -1,22 +1,10 @@
from __future__ import annotations
import json
from typing import Any
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
MIN_TON_BALANCE: float = 0.33
USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
MIN_USDT_BALANCE: float = 0.75
DEFAULT_TIMEOUT: float = 30.0
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
FRAGMENT_DOMAIN: str = "fragment.com"
FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}"
STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway"
PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift"
@@ -25,37 +13,10 @@ ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts"
SUPPORTED_BROWSERS: frozenset[str] = frozenset(
{
"arc",
"brave",
"chrome",
"chromium",
"chromium_based",
"edge",
"firefox",
"firefox_based",
"librewolf",
"opera",
"opera_gx",
"safari",
"vivaldi",
}
)
DEFAULT_TIMEOUT: float = 30.0
DEVICE: str = json.dumps(
{
"platform": "iphone",
"appName": "Tonkeeper",
"appVersion": "26.04.0",
"maxProtocolVersion": 2,
"features": [
"SendTransaction",
{"name": "SendTransaction", "maxMessages": 255},
{"name": "SignData", "types": ["text", "binary", "cell"]},
],
}
)
# Fragment cookie keys required for authenticated API calls
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
BASE_HEADERS: dict[str, str] = {
"accept": "application/json, text/javascript, */*; q=0.01",
@@ -63,15 +24,60 @@ BASE_HEADERS: dict[str, str] = {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"origin": FRAGMENT_BASE_URL,
"priority": "u=1, i",
"sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
"sec-ch-ua-mobile": "?1",
"sec-ch-ua-platform": '"Android"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": (
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
"Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36"
),
"x-requested-with": "XMLHttpRequest",
}
# USDT-TON jetton master contract address on GRAM (ex TON) mainnet
USDT_GRAM_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
# TON Connect device info sent during wallet connection handshake
DEVICE_INFO: dict[str, Any] = {
"platform": "iphone",
"appName": "Tonkeeper",
"appVersion": "26.05.0",
"maxProtocolVersion": 2,
"features": [
"SendTransaction",
{"name": "SendTransaction", "maxMessages": 255},
{"name": "SignData", "types": ["text", "binary", "cell"]},
],
}
# Stars: direct purchase per transaction
STARS_PURCHASE_MIN: int = 50
STARS_PURCHASE_MAX: int = 10_000_000
# Stars: giveaway amount per winner
STARS_GIVEAWAY_MIN: int = 500
STARS_GIVEAWAY_MAX: int = 1_000_000
# Stars giveaway winner count
STARS_WINNERS_MIN: int = 1
STARS_WINNERS_MAX: int = 15
# Premium giveaway winner count
PREMIUM_WINNERS_MIN: int = 1
PREMIUM_WINNERS_MAX: int = 24_000
# GRAM (ex TON) topup / Ads recharge amount
GRAM_TOPUP_MIN: int = 1
GRAM_TOPUP_MAX: int = 1_000_000_000
# Minimum wallet balances required before broadcasting a transaction
MIN_GRAM_BALANCE: float = 0.33
MIN_USDT_BALANCE: float = 0.75
# Premium subscription durations (months)
PREMIUM_MONTHS_VALID: frozenset[int] = frozenset({3, 6, 12})
# Mnemonic phrase valid word counts
MNEMONIC_WORD_COUNTS_VALID: frozenset[int] = frozenset({12, 24})
+17 -39
View File
@@ -5,55 +5,47 @@ import random
import re
from typing import Any, cast
import httpx
from curl_cffi.requests import AsyncSession, Response
from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
from pyfragment.exceptions import FragmentPageError, ParseError, VerificationError
from pyfragment.core.constants import FRAGMENT_BASE_URL
from pyfragment.exceptions import FragmentPageError, ParseError
async def get_fragment_hash(
cookies: dict[str, Any],
session: AsyncSession[Any],
headers: dict[str, str],
page_url: str,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
page_headers = {
k: v
for k, v in headers.items()
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
}
page_headers.update(
{
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"referer": f"{FRAGMENT_BASE_URL}/",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"upgrade-insecure-requests": "1",
}
)
# Derive the natural referer: strip the last path segment (e.g. /stars/buy → /stars)
parent_url = page_url.rsplit("/", 1)[0] or FRAGMENT_BASE_URL
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
response = await session.get(page_url, headers=page_headers)
page_headers = {k: v for k, v in headers.items() if k not in ("content-type", "origin")}
page_headers["referer"] = parent_url
page_headers["x-aj-referer"] = parent_url
page_headers.pop("x-aj-referer", None)
page_headers.pop("x-requested-with", None)
response = await session.get(page_url, headers=page_headers)
if response.status_code != 200:
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
match = re.search(r"(?:https://fragment\.com)?\\\\?/api\?hash=([a-f0-9]+)", response.text)
if not match:
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
return match.group(1)
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
def parse_json_response(response: Response, context: str) -> dict[str, Any]:
try:
return cast(dict[str, Any], response.json())
return cast(dict[str, Any], response.json()) # type: ignore[no-untyped-call]
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
async def fragment_request(
session: httpx.AsyncClient,
session: AsyncSession[Any],
fragment_hash: str,
headers: dict[str, str],
data: dict[str, Any],
@@ -73,17 +65,3 @@ async def fragment_request(
)
return parse_json_response(resp, data.get("method", "request"))
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
async def execute_transaction_request(
session: httpx.AsyncClient,
headers: dict[str, str],
tx_data: dict[str, Any],
fragment_hash: str,
) -> dict[str, Any]:
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
return transaction
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import json
from typing import Any, cast
from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID, REQUIRED_COOKIE_KEYS
from pyfragment.enums import ApiProvider, WalletVersion
from pyfragment.exceptions import ConfigurationError, CookieError
def parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]:
if isinstance(cookies, str):
try:
cookies = json.loads(cookies)
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
return cast(dict[str, Any], cookies)
def validate_cookie_keys(cookies: dict[str, Any]) -> None:
missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
if missing:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing)))
def normalize_provider(api_provider: str) -> ApiProvider:
try:
return ApiProvider(api_provider.strip().lower())
except ValueError:
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_PROVIDER.format(
provider=api_provider,
supported=", ".join(sorted(p.value for p in ApiProvider)),
)
)
def normalize_wallet_version(wallet_version: str) -> WalletVersion:
version = wallet_version.strip().upper()
try:
return WalletVersion(version)
except ValueError:
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_VERSION.format(
version=version,
supported=", ".join(sorted(m.value for m in WalletVersion)),
)
)
def validate_credentials(seed: str, api_key: str) -> None:
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
if missing:
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
word_count = len(seed.split())
if word_count not in MNEMONIC_WORD_COUNTS_VALID:
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
+2 -2
View File
@@ -1,5 +1,5 @@
from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.ads.service import AdsService
from pyfragment.domains.ads.tonup import topup_ton
from pyfragment.domains.ads.tonup import topup_gram
__all__ = ["AdsService", "recharge_ads", "topup_ton"]
__all__ = ["AdsService", "recharge_ads", "topup_gram"]
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class AdsTopupResult:
transaction_id: str
username: str
amount: int
def __repr__(self) -> str:
return f"AdsTopupResult(username='{self.username}', amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')"
@dataclass
class AdsRechargeResult:
transaction_id: str
amount: int
def __repr__(self) -> str:
return f"AdsRechargeResult(amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')"
+9 -9
View File
@@ -4,11 +4,11 @@ import json
import logging
from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
from pyfragment.domains.ads.models import AdsRechargeResult
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
from pyfragment.models.payments import AdsRechargeResult
from pyfragment.services.tonapi.account import get_account_info
from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -18,8 +18,8 @@ logger = logging.getLogger(__name__)
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT)
try:
await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
@@ -34,7 +34,7 @@ async def recharge_ads(client: FragmentClient, account: str, amount: int) -> Ads
"getAdsRechargeLink",
{
"account": json.dumps(account_info),
"device": DEVICE,
"device": json.dumps(DEVICE_INFO),
"transaction": 1,
"id": req_id,
},
@@ -47,8 +47,8 @@ async def recharge_ads(client: FragmentClient, account: str, amount: int) -> Ads
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
except FragmentError as exc:
logger.error("Failed to recharge Ads account '%s' for %s TON: %s", account, amount, exc, exc_info=True)
logger.error("Failed to recharge Ads account '%s' for %s GRAM (ex TON): %s", account, amount, exc, exc_info=True)
raise
except Exception as exc:
logger.exception("Failed to recharge Ads account '%s' for %s TON due to an unexpected error", account, amount)
logger.exception("Failed to recharge Ads account '%s' for %s GRAM (ex TON) due to an unexpected error", account, amount)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+4 -4
View File
@@ -2,10 +2,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult
from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.ads.tonup import topup_ton
from pyfragment.domains.ads.tonup import topup_gram
from pyfragment.domains.base import BaseService
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult
if TYPE_CHECKING:
pass
@@ -15,5 +15,5 @@ class AdsService(BaseService):
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
return await recharge_ads(self._client, account, amount)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
return await topup_ton(self._client, username, amount, show_sender=show_sender)
async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
return await topup_gram(self._client, username, amount, show_sender=show_sender)
+15 -11
View File
@@ -4,10 +4,9 @@ import json
import logging
from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
from pyfragment.domains.ads.models import AdsTopupResult
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError,
FragmentAPIError,
@@ -16,7 +15,8 @@ from pyfragment.exceptions import (
UserNotFoundError,
VerificationError,
)
from pyfragment.models.payments import AdsTopupResult
from pyfragment.services.tonapi.account import get_account_info
from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -25,9 +25,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
async def topup_gram(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT)
try:
await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
@@ -41,14 +41,14 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="GRAM (ex TON) topup"))
account = await get_account_info(client)
transaction = await client.call(
"getAdsTopupLink",
{
"account": json.dumps(account),
"device": DEVICE,
"device": json.dumps(DEVICE_INFO),
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
@@ -62,8 +62,12 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError as exc:
logger.error("Failed to top up TON for user '%s' with %s TON: %s", username, amount, exc, exc_info=True)
logger.error(
"Failed to top up GRAM (ex TON) for user '%s' with %s GRAM (ex TON): %s", username, amount, exc, exc_info=True
)
raise
except Exception as exc:
logger.exception("Failed to top up TON for user '%s' with %s TON due to an unexpected error", username, amount)
logger.exception(
"Failed to top up GRAM (ex TON) for user '%s' with %s GRAM (ex TON) due to an unexpected error", username, amount
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -1,6 +1,6 @@
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
__all__ = [
"AnonymousNumbersService",
@@ -21,6 +21,3 @@ class TerminateSessionsResult:
def __repr__(self) -> str:
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})"
__all__ = ["LoginCodeResult", "TerminateSessionsResult"]
@@ -5,9 +5,9 @@ import logging
from typing import TYPE_CHECKING
from pyfragment.core.constants import NUMBERS_PAGE
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
from pyfragment.domains.anonymous_numbers.parser import parse_login_code
from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -2,9 +2,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes
from pyfragment.domains.base import BaseService
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
if TYPE_CHECKING:
pass
+7 -5
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import httpx
from curl_cffi.requests import AsyncSession
from pyfragment.core.constants import BASE_HEADERS
from pyfragment.core.transport import fragment_request, get_fragment_hash
@@ -20,14 +20,16 @@ async def raw_api_call(
method: str,
data: dict[str, Any] | None,
page_url: str,
headers: dict[str, str] | None = None,
) -> dict[str, Any]:
base = headers if headers is not None else BASE_HEADERS
payload = {"method": method, **(data or {})}
headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
call_headers = {**base, "referer": page_url, "x-aj-referer": page_url}
logger.debug("Starting Fragment API call '%s' on %s", method, page_url)
try:
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
fragment_hash = await get_fragment_hash(cookies, headers, page_url, timeout)
response = await fragment_request(session, fragment_hash, headers, payload)
async with AsyncSession(cookies=cookies, timeout=timeout, impersonate="chrome") as session:
fragment_hash = await get_fragment_hash(session, call_headers, page_url)
response = await fragment_request(session, fragment_hash, call_headers, payload)
logger.debug("Completed Fragment API call '%s' with response keys: %s", method, sorted(response.keys()))
return response
except Exception:
+1 -1
View File
@@ -1,6 +1,6 @@
from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.domains.giveaways.service import GiveawaysService
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
__all__ = [
"GiveawaysService",
+46 -20
View File
@@ -3,12 +3,23 @@ from __future__ import annotations
import json
import logging
import random
from typing import TYPE_CHECKING, get_args
from typing import TYPE_CHECKING
from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
from pyfragment.core.constants import (
DEVICE_INFO,
PREMIUM_GIVEAWAY_PAGE,
PREMIUM_MONTHS_VALID,
PREMIUM_WINNERS_MAX,
PREMIUM_WINNERS_MIN,
STARS_GIVEAWAY_MAX,
STARS_GIVEAWAY_MIN,
STARS_GIVEAWAY_PAGE,
STARS_WINNERS_MAX,
STARS_WINNERS_MIN,
)
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.enums import PaymentMethod
from pyfragment.exceptions import (
ConfigurationError,
FragmentAPIError,
@@ -17,8 +28,8 @@ from pyfragment.exceptions import (
UserNotFoundError,
VerificationError,
)
from pyfragment.models.enums import PaymentMethod
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.services.tonapi.account import get_account_info
from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -27,22 +38,27 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _state_nonce() -> str:
# Fragment expects a pseudo-random nonce-like dh value in giveaway state updates.
return str(random.randint(100_000_000, 2_147_483_647))
async def giveaway_stars(
client: FragmentClient,
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsGiveawayResult:
if not isinstance(winners, int) or not (1 <= winners <= 5):
if not isinstance(winners, int) or not (STARS_WINNERS_MIN <= winners <= STARS_WINNERS_MAX):
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
if not isinstance(amount, int) or not (STARS_GIVEAWAY_MIN <= amount <= STARS_GIVEAWAY_MAX):
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
if payment_method not in get_args(PaymentMethod):
if not any(payment_method == m for m in PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
supported=", ".join(sorted(m.value for m in PaymentMethod)),
)
)
@@ -54,7 +70,12 @@ async def giveaway_stars(
await client.call(
"updateStarsGiveawayState",
{"mode": "new", "lv": "false", "dh": str(random.randint(100_000_000, 999_999_999))},
{"mode": "new", "lv": "false", "dh": _state_nonce()},
page_url=STARS_GIVEAWAY_PAGE,
)
await client.call(
"updateStarsGiveawayPrices",
{"quantity": winners, "stars": amount},
page_url=STARS_GIVEAWAY_PAGE,
)
@@ -78,7 +99,7 @@ async def giveaway_stars(
"getGiveawayStarsLink",
{
"account": json.dumps(account),
"device": DEVICE,
"device": json.dumps(DEVICE_INFO),
"transaction": 1,
"id": req_id,
},
@@ -122,17 +143,17 @@ async def giveaway_premium(
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumGiveawayResult:
if not isinstance(winners, int) or not (1 <= winners <= 24_000):
if not isinstance(winners, int) or not (PREMIUM_WINNERS_MIN <= winners <= PREMIUM_WINNERS_MAX):
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
if months not in (3, 6, 12):
if months not in PREMIUM_MONTHS_VALID:
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
if payment_method not in get_args(PaymentMethod):
if not any(payment_method == m for m in PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
supported=", ".join(sorted(m.value for m in PaymentMethod)),
)
)
@@ -151,11 +172,16 @@ async def giveaway_premium(
{
"mode": "new",
"lv": "false",
"dh": str(random.randint(100_000_000, 999_999_999)),
"dh": _state_nonce(),
"quantity": "",
},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
await client.call(
"updatePremiumGiveawayPrices",
{"quantity": winners},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
result = await client.call(
"initGiveawayPremiumRequest",
@@ -177,7 +203,7 @@ async def giveaway_premium(
"getGiveawayPremiumLink",
{
"account": json.dumps(account),
"device": DEVICE,
"device": json.dumps(DEVICE_INFO),
"transaction": 1,
"id": req_id,
},
@@ -29,6 +29,3 @@ class PremiumGiveawayResult:
f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, "
f"amount={self.amount} months per winner, tx='{self.transaction_id}')"
)
__all__ = ["PremiumGiveawayResult", "StarsGiveawayResult"]
+4 -4
View File
@@ -4,8 +4,8 @@ from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
from pyfragment.models.enums import PaymentMethod
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.enums import PaymentMethod
if TYPE_CHECKING:
pass
@@ -17,7 +17,7 @@ class GiveawaysService(BaseService):
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsGiveawayResult:
return await giveaway_stars(self._client, channel, winners, amount, payment_method=payment_method)
@@ -26,6 +26,6 @@ class GiveawaysService(BaseService):
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumGiveawayResult:
return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method)
+1 -1
View File
@@ -1,6 +1,6 @@
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
from pyfragment.domains.marketplace.service import MarketplaceService
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
__all__ = [
"GiftsResult",
@@ -29,6 +29,3 @@ class GiftsResult:
def __repr__(self) -> str:
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})"
__all__ = ["GiftsResult", "NumbersResult", "UsernamesResult"]
+1 -1
View File
@@ -4,9 +4,9 @@ import logging
from typing import TYPE_CHECKING, Any
from pyfragment.core.constants import FRAGMENT_BASE_URL, GIFTS_PAGE, NUMBERS_PAGE
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.domains.marketplace.parser import parse_auction_rows, parse_gift_items
from pyfragment.exceptions import FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
+1 -1
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
if TYPE_CHECKING:
pass
+1 -1
View File
@@ -1,5 +1,5 @@
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars
from pyfragment.domains.purchases.service import PurchasesService
from pyfragment.models.payments import PremiumResult, StarsResult
__all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"]
@@ -21,25 +21,3 @@ class StarsResult:
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"]
+39 -19
View File
@@ -2,14 +2,22 @@ from __future__ import annotations
import json
import logging
import time
from typing import TYPE_CHECKING, get_args
import random
from typing import TYPE_CHECKING
from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE
from pyfragment.core.constants import (
DEVICE_INFO,
PREMIUM_MONTHS_VALID,
PREMIUM_PAGE,
STARS_PAGE,
STARS_PURCHASE_MAX,
STARS_PURCHASE_MIN,
)
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
from pyfragment.enums import PaymentMethod
from pyfragment.exceptions import (
AlreadySubscribedError,
ConfigurationError,
FragmentAPIError,
FragmentError,
@@ -17,8 +25,8 @@ from pyfragment.exceptions import (
UserNotFoundError,
VerificationError,
)
from pyfragment.models.enums import PaymentMethod
from pyfragment.models.payments import PremiumResult, StarsResult
from pyfragment.services.tonapi.account import get_account_info
from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -27,32 +35,39 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _state_nonce() -> str:
# Fragment accepts a pseudo-random request nonce in state update methods.
return str(random.randint(100_000_000, 2_147_483_647))
async def purchase_stars(
client: FragmentClient,
username: str,
amount: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsResult:
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
if not isinstance(amount, int) or not (STARS_PURCHASE_MIN <= amount <= STARS_PURCHASE_MAX):
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
if payment_method not in get_args(PaymentMethod):
if not any(payment_method == m for m in PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
supported=", ".join(sorted(m.value for m in PaymentMethod)),
)
)
try:
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
if "assigned to a user" in str(result.get("error", "")).lower():
raise UserNotFoundError(UserNotFoundError.NOT_A_USER.format(username=username))
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
await client.call(
"updateStarsBuyState",
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
{"mode": "new", "lv": "false", "dh": _state_nonce()},
page_url=STARS_PAGE,
)
result = await client.call(
@@ -70,7 +85,7 @@ async def purchase_stars(
"getBuyStarsLink",
{
"account": json.dumps(account),
"device": DEVICE,
"device": json.dumps(DEVICE_INFO),
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
@@ -113,27 +128,29 @@ async def purchase_premium(
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumResult:
if months not in (3, 6, 12):
if months not in PREMIUM_MONTHS_VALID:
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
if payment_method not in get_args(PaymentMethod):
if not any(payment_method == m for m in PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
supported=", ".join(sorted(m.value for m in PaymentMethod)),
)
)
try:
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
if "assigned to a user" in str(result.get("error", "")).lower():
raise UserNotFoundError(UserNotFoundError.NOT_A_USER.format(username=username))
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
await client.call(
"updatePremiumState",
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
{"mode": "new", "lv": "false", "dh": _state_nonce()},
page_url=PREMIUM_PAGE,
)
result = await client.call(
@@ -141,6 +158,9 @@ async def purchase_premium(
{"recipient": recipient, "months": months, "payment_method": payment_method},
page_url=PREMIUM_PAGE,
)
error_text = str(result.get("error", "")).strip().lower()
if "already subscribed to telegram premium" in error_text:
raise AlreadySubscribedError(AlreadySubscribedError.PREMIUM_ACTIVE)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
@@ -151,7 +171,7 @@ async def purchase_premium(
"getGiftPremiumLink",
{
"account": json.dumps(account),
"device": DEVICE,
"device": json.dumps(DEVICE_INFO),
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
+4 -4
View File
@@ -3,9 +3,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars
from pyfragment.models.enums import PaymentMethod
from pyfragment.models.payments import PremiumResult, StarsResult
from pyfragment.enums import PaymentMethod
if TYPE_CHECKING:
pass
@@ -17,7 +17,7 @@ class PurchasesService(BaseService):
username: str,
amount: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsResult:
return await purchase_stars(self._client, username, amount, show_sender=show_sender, payment_method=payment_method)
@@ -26,6 +26,6 @@ class PurchasesService(BaseService):
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumResult:
return await purchase_premium(self._client, username, months, show_sender=show_sender, payment_method=payment_method)
-5
View File
@@ -1,5 +0,0 @@
from pyfragment.domains.tonapi.service import TonapiService
__all__ = [
"TonapiService",
]
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from enum import StrEnum
from typing import Any
from tonutils.contracts.wallet import WalletHighloadV2, WalletHighloadV3R1, WalletV4R2, WalletV5R1
class PaymentMethod(StrEnum):
GRAM = "ton"
USDT_GRAM = "usdt_ton"
# Not supported yet
USDT_ETH = "usdt_eth"
USDT_POL = "usdt_pol"
USDC_ETH = "usdc_eth"
USDC_BASE = "usdc_base"
USDC_POL = "usdc_pol"
class WalletVersion(StrEnum):
V4R2 = "V4R2"
V5R1 = "V5R1"
HighloadV2 = "HighloadV2"
HighloadV3R1 = "HighloadV3R1"
WALLET_CLASSES: dict[WalletVersion, Any] = {
WalletVersion.V4R2: WalletV4R2,
WalletVersion.V5R1: WalletV5R1,
WalletVersion.HighloadV2: WalletHighloadV2,
WalletVersion.HighloadV3R1: WalletHighloadV3R1,
}
class ApiProvider(StrEnum):
TONAPI = "tonapi" # tonconsole.com — default
TONCENTER = "toncenter" # t.me/toncenter
class SupportedBrowser(StrEnum):
ARC = "arc"
BRAVE = "brave"
CHROME = "chrome"
CHROMIUM = "chromium"
CHROMIUM_BASED = "chromium_based"
EDGE = "edge"
FIREFOX = "firefox"
FIREFOX_BASED = "firefox_based"
LIBREWOLF = "librewolf"
OPERA = "opera"
OPERA_GX = "opera_gx"
SAFARI = "safari"
VIVALDI = "vivaldi"
+47 -20
View File
@@ -1,5 +1,20 @@
from __future__ import annotations
from pyfragment.core.constants import (
GRAM_TOPUP_MAX,
GRAM_TOPUP_MIN,
MNEMONIC_WORD_COUNTS_VALID,
PREMIUM_MONTHS_VALID,
PREMIUM_WINNERS_MAX,
PREMIUM_WINNERS_MIN,
STARS_GIVEAWAY_MAX,
STARS_GIVEAWAY_MIN,
STARS_PURCHASE_MAX,
STARS_PURCHASE_MIN,
STARS_WINNERS_MAX,
STARS_WINNERS_MIN,
)
class FragmentError(Exception):
"""Base exception for all pyfragment errors."""
@@ -14,20 +29,22 @@ class ConfigurationError(ClientError):
MISSING_VARS = "Missing required parameter(s): {keys}."
UNSUPPORTED_VERSION = "Unsupported wallet version '{version}'. Supported values: {supported}."
INVALID_MNEMONIC = "Invalid mnemonic phrase: expected 12, 18, or 24 words, got {count}."
INVALID_API_KEY = (
"Invalid Tonapi API key: expected at least 68 characters, got {length}. Get a key at https://tonconsole.com."
INVALID_MNEMONIC = f"Invalid mnemonic phrase: expected {', '.join(str(n) for n in sorted(MNEMONIC_WORD_COUNTS_VALID))} words, got {{count}}."
UNSUPPORTED_PROVIDER = "Unsupported API provider '{provider}'. Supported values: {supported}."
INVALID_MONTHS = f"Invalid Premium duration: choose {', '.join(str(m) for m in sorted(PREMIUM_MONTHS_VALID))} months."
INVALID_STARS_AMOUNT = (
f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}."
)
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_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1,000,000,000."
INVALID_USERNAME = (
"Invalid username '{username}'. "
"Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)."
INVALID_GRAM_AMOUNT = f"Invalid GRAM (ex TON) amount: must be an integer between {GRAM_TOPUP_MIN:,} and {GRAM_TOPUP_MAX:,}."
INVALID_WINNERS_STARS = (
f"Invalid winners count: must be an integer between {STARS_WINNERS_MIN:,} and {STARS_WINNERS_MAX:,}."
)
INVALID_WINNERS_PREMIUM = (
f"Invalid winners count: must be an integer between {PREMIUM_WINNERS_MIN:,} and {PREMIUM_WINNERS_MAX:,}."
)
INVALID_STARS_PER_WINNER = (
f"Invalid Stars per winner: must be an integer between {STARS_GIVEAWAY_MIN:,} and {STARS_GIVEAWAY_MAX:,}."
)
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24,000."
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1,000,000."
INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
@@ -45,7 +62,7 @@ class CookieError(ClientError):
)
MISSING_BROWSER_KEYS = (
"Fragment cookies not found in {browser}: {keys}. "
"Make sure you are logged in to {url} and have connected your TON wallet in {browser}."
"Make sure you are logged in to {url} and have connected your GRAM (ex TON) wallet in {browser}."
)
EXPIRED = "Fragment session cookie expired at {expires}. Log in to fragment.com in your browser and extract fresh cookies."
@@ -69,6 +86,13 @@ class UserNotFoundError(FragmentAPIError):
NOT_FOUND = (
"Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
)
NOT_A_USER = "'{username}' does not belong to a user account. Make sure the username is assigned to a personal Telegram account, not a channel or bot."
class AlreadySubscribedError(FragmentAPIError):
"""Raised when trying to gift Premium to a user who already has an active subscription."""
PREMIUM_ACTIVE = "This account is already subscribed to Telegram Premium."
class AnonymousNumberError(FragmentAPIError):
@@ -79,7 +103,7 @@ class AnonymousNumberError(FragmentAPIError):
class TransactionError(FragmentAPIError):
"""Raised when a TON transaction fails to build or broadcast."""
"""Raised when a GRAM (ex TON) transaction fails to build or broadcast."""
INVALID_PAYLOAD = "Fragment returned an invalid transaction payload: 'transaction.messages' is missing or empty."
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
@@ -91,7 +115,7 @@ class TransactionError(FragmentAPIError):
"located in your Python installation folder."
)
DUPLICATE_SEQNO = (
"Transaction broadcast failed: the TON wallet rejected the message "
"Transaction broadcast failed: the GRAM (ex TON) wallet rejected the message "
"because a previous transaction with the same sequence number (seqno) "
"is still pending confirmation on-chain.\n"
"Wait a few seconds for the previous transaction to confirm, then retry."
@@ -118,14 +142,16 @@ class OperationError(FragmentError):
class WalletError(OperationError):
"""Raised for TON wallet issues (connection, balance, account info)."""
"""Raised for GRAM (ex TON) wallet issues (connection, balance, account info)."""
LOW_TON_BALANCE = "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required."
LOW_GRAM_BALANCE = (
"Insufficient GRAM (ex TON) balance: {balance:.4f} GRAM (ex TON) available, {required:.4f} GRAM (ex TON) required."
)
LOW_USDT_BALANCE = "Insufficient USDT balance: {balance:.4f} USDT available, {required:.4f} USDT required."
TON_BALANCE_CHECK_FAILED = "Failed to fetch TON balance: {exc}"
GRAM_BALANCE_CHECK_FAILED = "Failed to fetch GRAM (ex TON) balance: {exc}"
USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}"
WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from GRAM (ex TON) network: {exc}"
WALLET_INFO_FAILED = "Failed to retrieve wallet info from GRAM (ex TON) network: {exc}"
class UnexpectedError(OperationError):
@@ -142,6 +168,7 @@ __all__ = [
"FragmentAPIError",
"FragmentPageError",
"AnonymousNumberError",
"AlreadySubscribedError",
"UserNotFoundError",
"TransactionError",
"ParseError",
-25
View File
@@ -1,25 +0,0 @@
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.models.cookies import CookieResult
from pyfragment.models.enums import PaymentMethod, WalletVersion
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import WalletInfo
__all__ = [
"AdsRechargeResult",
"AdsTopupResult",
"CookieResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
"PaymentMethod",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult",
"UsernamesResult",
"WalletInfo",
"WalletVersion",
]
-8
View File
@@ -1,8 +0,0 @@
from __future__ import annotations
from typing import Literal
PaymentMethod = Literal["ton", "usdt_ton"]
WalletVersion = Literal["V4R2", "V5R1"]
__all__ = ["PaymentMethod", "WalletVersion"]
+3
View File
@@ -0,0 +1,3 @@
from pyfragment.services.cookies import CookieResult, get_cookies_from_browser
__all__ = ["CookieResult", "get_cookies_from_browser"]
+4
View File
@@ -0,0 +1,4 @@
from pyfragment.services.cookies.models import CookieResult
from pyfragment.services.cookies.service import get_cookies_from_browser
__all__ = ["CookieResult", "get_cookies_from_browser"]
@@ -10,6 +10,3 @@ class CookieResult:
def __repr__(self) -> str:
return f"CookieResult(cookies={self.cookies!r}, expires={self.expires!r})"
__all__ = ["CookieResult"]
@@ -1,12 +1,13 @@
from __future__ import annotations
import importlib
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS
from pyfragment.enums import SupportedBrowser
from pyfragment.exceptions import CookieError
from pyfragment.models.cookies import CookieResult
from pyfragment.services.cookies.models import CookieResult
try:
import rookiepy
@@ -18,8 +19,8 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
global rookiepy
key = browser.lower()
if key not in SUPPORTED_BROWSERS:
supported = ", ".join(sorted(SUPPORTED_BROWSERS))
if not any(key == m for m in SupportedBrowser):
supported = ", ".join(sorted(b.value for b in SupportedBrowser))
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
try:
@@ -41,11 +42,11 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
if cookie.get("name") == "stel_ssid":
raw = cookie.get("expires")
if isinstance(raw, (int, float)):
expires_iso = datetime.fromtimestamp(raw, tz=timezone.utc).isoformat()
expires_iso = datetime.fromtimestamp(raw, tz=UTC).isoformat()
elif isinstance(raw, str) and raw:
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
try:
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc).isoformat()
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=UTC).isoformat()
break
except ValueError:
continue
@@ -53,7 +54,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
if expires_iso:
expires_dt = datetime.fromisoformat(expires_iso)
if expires_dt < datetime.now(timezone.utc):
if expires_dt < datetime.now(UTC):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso)
+5
View File
@@ -0,0 +1,5 @@
from pyfragment.services.tonapi.service import TonapiService
__all__ = [
"TonapiService",
]
@@ -5,13 +5,14 @@ import logging
from typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient
from tonutils.clients import TonapiClient, ToncenterClient
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS, WALLET_CLASSES
from pyfragment.core.constants import MIN_GRAM_BALANCE, MIN_USDT_BALANCE, USDT_GRAM_MASTER_ADDRESS
from pyfragment.enums import WALLET_CLASSES, ApiProvider
from pyfragment.exceptions import WalletError
from pyfragment.models.wallet import WalletInfo
from pyfragment.services.tonapi.models import WalletInfo
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -20,12 +21,19 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _make_ton_client(client: FragmentClient) -> Any:
"""Return the appropriate tonutils client based on the configured api_provider."""
if client.api_provider == ApiProvider.TONCENTER:
return ToncenterClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key)
return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key)
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
"""Return the USDT balance for a Fragment-linked TON wallet."""
"""Return the USDT balance for a Fragment-linked GRAM (ex TON) wallet."""
try:
jetton_wallet_address = await get_wallet_address_get_method(
client=ton,
address=USDT_TON_MASTER_ADDRESS,
address=USDT_GRAM_MASTER_ADDRESS,
owner_address=wallet_address,
)
wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address)
@@ -42,40 +50,40 @@ async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def check_ton_payment_balance(
balance_ton: float,
amount_ton: float,
async def check_gram_payment_balance(
balance_gram: float,
amount_gram: float,
required_payment_amount: float | None,
) -> None:
"""Validate that the TON wallet can cover a TON-denominated payment."""
tx_price_ton = amount_ton
"""Validate that the GRAM (ex TON) wallet can cover a GRAM (ex TON)-denominated payment."""
tx_price_gram = amount_gram
if required_payment_amount is not None and required_payment_amount > 0:
tx_price_ton = max(tx_price_ton, required_payment_amount)
tx_price_gram = max(tx_price_gram, required_payment_amount)
required_ton = max(tx_price_ton, MIN_TON_BALANCE)
if balance_ton < required_ton:
required_gram = max(tx_price_gram, MIN_GRAM_BALANCE)
if balance_gram < required_gram:
logger.error(
"Failed TON balance check: balance=%s TON, required=%s TON",
round(balance_ton, 6),
round(required_ton, 6),
"Failed GRAM (ex TON) balance check: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)",
round(balance_gram, 6),
round(required_gram, 6),
)
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton))
raise WalletError(WalletError.LOW_GRAM_BALANCE.format(balance=balance_gram, required=required_gram))
async def check_usdt_payment_balance(
balance_ton: float,
balance_gram: float,
required_payment_amount: float | None,
ton: Any,
wallet_address: str,
) -> None:
"""Validate that the wallet can cover a USDT-denominated payment."""
if balance_ton < MIN_TON_BALANCE:
if balance_gram < MIN_GRAM_BALANCE:
logger.error(
"Failed TON gas reserve check for USDT payment: balance=%s TON, required=%s TON",
round(balance_ton, 6),
MIN_TON_BALANCE,
"Failed GRAM (ex TON) gas reserve check for USDT payment: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)",
round(balance_gram, 6),
MIN_GRAM_BALANCE,
)
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE))
raise WalletError(WalletError.LOW_GRAM_BALANCE.format(balance=balance_gram, required=MIN_GRAM_BALANCE))
usdt_balance = await get_usdt_balance(ton, wallet_address)
required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE
@@ -91,7 +99,7 @@ async def check_usdt_payment_balance(
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"""Build the wallet payload Fragment needs to prepare a transaction."""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
async with _make_ton_client(client) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
@@ -108,8 +116,8 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]:
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Fetch the wallet address, chain state, and TON/USDT balances."""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
"""Fetch the wallet address, chain state, and GRAM (ex TON)/USDT balances."""
async with _make_ton_client(client) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
@@ -119,7 +127,7 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo:
return WalletInfo(
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
state=wallet.state.value,
ton_balance=round(wallet.balance / 1_000_000_000, 4),
gram_balance=round(wallet.balance / 1_000_000_000, 4),
usdt_balance=round(usdt_balance, 4),
)
except Exception as exc:
@@ -7,14 +7,11 @@ from dataclasses import dataclass
class WalletInfo:
address: str
state: str
ton_balance: float
gram_balance: float
usdt_balance: float
def __repr__(self) -> str:
return (
f"WalletInfo(address='{self.address}', state='{self.state}', "
f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)"
f"gram_balance={self.gram_balance} GRAM (ex TON), usdt_balance={self.usdt_balance} USDT)"
)
__all__ = ["WalletInfo"]
@@ -3,8 +3,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.tonapi.account import get_wallet_info
from pyfragment.models.wallet import WalletInfo
from pyfragment.services.tonapi.account import get_wallet_info
from pyfragment.services.tonapi.models import WalletInfo
if TYPE_CHECKING:
pass
@@ -7,14 +7,12 @@ import random
import ssl
from typing import TYPE_CHECKING, Any
from ton_core import Cell, NetworkGlobalID
from tonutils.clients import TonapiClient
from ton_core import Cell
from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import WALLET_CLASSES
from pyfragment.domains.tonapi.account import check_ton_payment_balance, check_usdt_payment_balance
from pyfragment.enums import WALLET_CLASSES, PaymentMethod
from pyfragment.exceptions import ParseError, TransactionError, WalletError
from pyfragment.models.enums import PaymentMethod
from pyfragment.services.tonapi.account import _make_ton_client, check_gram_payment_balance, check_usdt_payment_balance
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
@@ -27,7 +25,7 @@ def clean_decode(payload: str) -> str | Cell:
"""Decode a base64 BOC comment from Fragment into text when possible.
Some Fragment payloads are plain text comments, while others are structured
TON messages such as jetton transfers. Non-text payloads are returned as a
GRAM (ex TON) messages such as jetton transfers. Non-text payloads are returned as a
`Cell` so the caller can keep the raw binary structure.
"""
s = payload.strip()
@@ -40,7 +38,7 @@ def clean_decode(payload: str) -> str | Cell:
sl = cell.begin_parse()
op = sl.load_uint(32)
if op != 0:
# Non-zero op code means this is a structured TON message, not a plain text comment.
# Non-zero op code means this is a structured GRAM (ex TON) message, not a plain text comment.
return cell
try:
return sl.load_snake_string().strip()
@@ -63,7 +61,7 @@ def _extract_message(transaction_data: dict[str, Any]) -> dict[str, Any]:
async def _check_payment_balances(
wallet: Any,
payment_method: PaymentMethod,
amount_ton: float,
amount_gram: float,
required_payment_amount: float | None,
transaction_data: dict[str, Any],
ton: Any,
@@ -71,18 +69,18 @@ async def _check_payment_balances(
"""Refresh wallet and verify sufficient balance before broadcasting."""
try:
await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000
balance_gram = wallet.balance / 1_000_000_000
if payment_method == "ton":
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
await check_gram_payment_balance(balance_gram, amount_gram, required_payment_amount)
else:
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
await check_usdt_payment_balance(balance_gram, required_payment_amount, ton, fragment_wallet_address)
except WalletError:
raise
except Exception as exc:
logger.exception("Failed to validate balances before broadcasting transaction")
raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
raise WalletError(WalletError.GRAM_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: str | Cell) -> str:
@@ -91,7 +89,7 @@ async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: s
try:
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON
amount=int(message["amount"]), # nanograms, not GRAM (ex TON)
body=payload,
)
return str(result.normalized_hash)
@@ -125,10 +123,10 @@ async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: s
async def process_transaction(
client: FragmentClient,
transaction_data: dict[str, Any],
payment_method: PaymentMethod = "ton",
payment_method: PaymentMethod = PaymentMethod.GRAM,
required_payment_amount: float | None = None,
) -> str:
"""Sign and broadcast a Fragment transaction with the seeded TON wallet.
"""Sign and broadcast a Fragment transaction with the seeded GRAM (ex TON) wallet.
Args:
client: Authenticated `FragmentClient` instance.
@@ -140,13 +138,13 @@ async def process_transaction(
Normalized transaction hash string.
"""
message = _extract_message(transaction_data)
amount_ton = int(message["amount"]) / 1_000_000_000
amount_gram = int(message["amount"]) / 1_000_000_000
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
async with _make_ton_client(client) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await _check_payment_balances(wallet, payment_method, amount_ton, required_payment_amount, transaction_data, ton)
await _check_payment_balances(wallet, payment_method, amount_gram, required_payment_amount, transaction_data, ton)
payload = clean_decode(str(message.get("payload", "")))
@@ -162,7 +160,7 @@ async def process_transaction(
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
cause = cause.__cause__ or cause.__context__
logger.exception(
"Failed to broadcast transaction to '%s' for %s nanotons using payment method '%s'",
"Failed to broadcast transaction to '%s' for %s nanograms using payment method '%s'",
message["address"],
message["amount"],
payment_method,
+15 -32
View File
@@ -4,62 +4,43 @@ build-backend = "hatchling.build"
[project]
name = "pyfragment"
version = "2026.3.1"
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."
version = "2026.3.3"
description = "Async Python client for the Fragment.com marketplace API."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
requires-python = ">=3.11"
authors = [{ name = "bohd4nx" }]
keywords = [
"fragment",
"fragment-api",
"telegram",
"telegram-api",
"telegram-stars",
"telegram-premium",
"telegram-giveaway",
"telegram-ads",
"ton",
"ton-blockchain",
"tonapi",
"anonymous-numbers",
"username-auctions",
"gift-marketplace",
"crypto-payments",
"nft-marketplace",
"web3",
"python-client",
"typed",
"asyncio",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Framework :: AsyncIO",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet",
"Topic :: Internet :: WWW/HTTP",
"Typing :: Typed",
]
dependencies = ["httpx>=0.25", "tonutils>=2.0.1"]
dependencies = ["curl-cffi>=0.14.0", "tonutils>=2.0.1"]
[project.optional-dependencies]
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
browser = ["rookiepy>=0.5.6"]
[project.urls]
Homepage = "https://github.com/bohd4nx/pyfragment"
Repository = "https://github.com/bohd4nx/pyfragment"
Documentation = "https://bohd4nx.gitbook.io/pyfragment/"
Issues = "https://github.com/bohd4nx/pyfragment/issues"
@@ -76,29 +57,31 @@ addopts = "-v --tb=short"
[tool.ruff]
line-length = 128
target-version = "py310"
target-version = "py311"
[tool.ruff.lint]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
select = ["E", "F", "W", "I", "UP"]
# E501 — line too long (covered by line-length above)
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
ignore = ["E501", "UP017"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"]
[tool.mypy]
python_version = "3.10"
python_version = "3.11"
strict = true
exclude = ["^examples/"]
exclude = ["^examples/", "^logs/", "^tests/"]
[[tool.mypy.overrides]]
module = "rookiepy"
module = [
"rookiepy",
"ton_core",
"tonutils",
"tonutils.*",
]
ignore_missing_imports = true
[[tool.mypy.overrides]]
# rookiepy = None assignment is valid when the package is installed (type: ModuleType)
# but mypy on CI (without rookiepy) sees it as Any and flags the comment as unused.
module = "pyfragment.core.cookies"
module = "pyfragment.services.cookies.service"
warn_unused_ignores = false
+4 -4
View File
@@ -8,7 +8,7 @@ import pytest
from ton_core import Cell
from pyfragment import ParseError
from pyfragment.domains.tonapi.transaction import clean_decode
from pyfragment.services.tonapi.transaction import clean_decode
PAYLOAD_CASES = [
pytest.param(
@@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None:
raw = b"\xfb\xef\xff\x00"
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
result = clean_decode(payload)
mocked.assert_called_once_with(raw)
@@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None:
return _FakeSlice()
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()):
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()):
parsed = clean_decode(payload)
assert parsed == "Telegram Premium Ref#abc"
@@ -126,7 +126,7 @@ def test_clean_decode_returns_cell_for_binary_payload() -> None:
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
fake_cell: object = _FakeCell()
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell):
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell):
parsed = clean_decode(payload)
assert parsed is fake_cell
+32 -6
View File
@@ -5,6 +5,7 @@ import json
import pytest
from pyfragment import ConfigurationError, CookieError, FragmentClient
from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
# Client init tests
@@ -15,6 +16,30 @@ def test_valid_init() -> None:
assert client.seed == VALID_SEED.strip()
assert client.api_key == VALID_API_KEY
assert client.wallet_version == "V5R1"
assert client.api_provider == "tonapi"
# API provider tests
def test_api_provider_default_is_tonapi() -> None:
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
assert client.api_provider == "tonapi"
def test_api_provider_toncenter() -> None:
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="toncenter")
assert client.api_provider == "toncenter"
def test_api_provider_is_case_insensitive() -> None:
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="TONAPI")
assert client.api_provider == "tonapi"
def test_unsupported_api_provider_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="infura")
# Wallet version tests
@@ -43,6 +68,11 @@ def test_missing_seed_raises() -> None:
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
def test_both_seed_and_api_key_missing_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed="", api_key="", cookies=VALID_COOKIES)
def test_whitespace_only_seed_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
@@ -55,7 +85,7 @@ def test_invalid_mnemonic_length_raises() -> None:
def test_valid_mnemonic_lengths() -> None:
for length in (12, 18, 24):
for length in sorted(MNEMONIC_WORD_COUNTS_VALID):
seed = " ".join(["abandon"] * (length - 1) + ["about"])
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
assert len(client.seed.split()) == length
@@ -69,11 +99,6 @@ def test_missing_api_key_raises() -> None:
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
def test_short_api_key_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
# Cookie validation tests
@@ -109,6 +134,7 @@ def test_repr() -> None:
r = repr(client)
assert "FragmentClient" in r
assert "V5R1" in r
assert "tonapi" in r
assert "4 keys" in r
+24 -22
View File
@@ -1,4 +1,4 @@
"""Exercise transaction signing, balance checks, and retry behavior for TON and USDT flows."""
"""Exercise transaction signing, balance checks, and retry behavior for GRAM (ex TON) and USDT flows."""
from collections.abc import Generator
from contextlib import contextmanager
@@ -8,7 +8,8 @@ import pytest
from tonutils.exceptions import ProviderResponseError
from pyfragment import TransactionError, WalletError
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.enums import PaymentMethod
from pyfragment.services.tonapi.transaction import process_transaction
from tests.shared import VALID_SEED
@@ -21,7 +22,7 @@ TRANSACTION_DATA = {
"messages": [
{
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
"amount": "500000000", # 0.5 TON
"amount": "500000000", # 0.5 GRAM
"payload": "",
}
]
@@ -47,12 +48,13 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
@contextmanager
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
mock_ton_ctx = MagicMock()
mock_ton_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
mock_ton_ctx.__aexit__ = AsyncMock(return_value=False)
with (
patch("pyfragment.domains.tonapi.transaction.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.tonapi.transaction.WALLET_CLASSES") as mock_classes,
patch("pyfragment.services.tonapi.transaction._make_ton_client", return_value=mock_ton_ctx),
patch("pyfragment.services.tonapi.transaction.WALLET_CLASSES") as mock_classes,
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
yield
@@ -62,8 +64,8 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
@pytest.mark.asyncio
async def test_sufficient_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 GRAM, above threshold
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
wallet.transfer.assert_called_once()
@@ -71,7 +73,7 @@ async def test_sufficient_balance_broadcasts() -> None:
@pytest.mark.asyncio
async def test_insufficient_balance_raises() -> None:
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, below threshold
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 GRAM, below threshold
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -81,14 +83,14 @@ async def test_insufficient_balance_raises() -> None:
@pytest.mark.asyncio
async def test_exact_minimum_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
@pytest.mark.asyncio
async def test_one_nanoton_below_minimum_raises() -> None:
wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanoton below transaction amount threshold
wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanogram below transaction amount threshold
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -123,7 +125,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
async def test_rate_limit_retries_and_succeeds() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000)
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
assert wallet.transfer.call_count == 2
@@ -134,18 +136,18 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000)
err = _provider_error(406, "Duplicate msg_seqno")
wallet.transfer = AsyncMock(side_effect=[err, err, err])
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""):
with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3
@pytest.mark.asyncio
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient TON balance"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
async def test_usdt_payment_requires_min_gram_gas_reserve() -> None:
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 GRAM below MIN_GRAM_BALANCE
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient GRAM"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method=PaymentMethod.USDT_GRAM)
@pytest.mark.asyncio
@@ -166,13 +168,13 @@ async def test_usdt_payment_checks_usdt_balance() -> None:
with (
_patch_wallet(wallet),
patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""),
patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)),
patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""),
patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)),
):
with pytest.raises(WalletError, match="Insufficient USDT balance"):
await process_transaction(
_make_client(),
transaction,
payment_method="usdt_ton",
payment_method=PaymentMethod.USDT_GRAM,
required_payment_amount=12.5,
)
+125 -9
View File
@@ -7,6 +7,15 @@ import pytest
import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod
import pyfragment.domains.purchases.purchase as _purchase_stars_mod
from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError
from pyfragment.core.constants import (
STARS_GIVEAWAY_MAX,
STARS_GIVEAWAY_MIN,
STARS_PURCHASE_MAX,
STARS_PURCHASE_MIN,
STARS_WINNERS_MAX,
STARS_WINNERS_MIN,
)
from pyfragment.enums import PaymentMethod
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Stars purchase validation tests
@@ -15,13 +24,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSAC
@pytest.mark.asyncio
async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.purchase_stars("@user", amount=49)
await client.purchase_stars("@user", amount=STARS_PURCHASE_MIN - 1)
@pytest.mark.asyncio
async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.purchase_stars("@user", amount=1_000_001)
await client.purchase_stars("@user", amount=STARS_PURCHASE_MAX + 1)
@pytest.mark.asyncio
@@ -78,7 +87,7 @@ async def test_purchase_stars_passes_payment_method(client: FragmentClient) -> N
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_stars_mod, "process_transaction", proc_mock),
):
await client.purchase_stars("@user", amount=500, payment_method="usdt_ton")
await client.purchase_stars("@user", amount=500, payment_method=PaymentMethod.USDT_GRAM)
init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initBuyStarsRequest"
@@ -113,25 +122,25 @@ async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=0, amount=500)
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN - 1, amount=STARS_GIVEAWAY_MIN)
@pytest.mark.asyncio
async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=6, amount=500)
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MAX + 1, amount=STARS_GIVEAWAY_MIN)
@pytest.mark.asyncio
async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=1, amount=499)
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MIN - 1)
@pytest.mark.asyncio
async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=1, amount=1_000_001)
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MAX + 1)
@pytest.mark.asyncio
@@ -165,6 +174,7 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{},
{},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
@@ -188,6 +198,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{},
{},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
@@ -198,9 +209,9 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_giveaway_stars_mod, "process_transaction", proc_mock),
):
await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton")
await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method=PaymentMethod.USDT_GRAM)
init_call = call_mock.await_args_list[2]
init_call = call_mock.await_args_list[3]
assert init_call.args[0] == "initGiveawayStarsRequest"
assert init_call.args[1]["payment_method"] == "usdt_ton"
assert proc_mock.await_args is not None
@@ -225,3 +236,108 @@ async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.giveaway_stars("@ghost", winners=1, amount=500)
# Stars purchase — error branches
@pytest.mark.asyncio
async def test_purchase_stars_not_a_user_raises(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"error": "Please enter a username assigned to a user."})):
with pytest.raises(UserNotFoundError, match="does not belong"):
await client.purchase_stars("@channel", amount=500)
@pytest.mark.asyncio
async def test_purchase_stars_missing_req_id_raises(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updateStarsBuyState
{"amount": "0.1"}, # initBuyStarsRequest — no req_id
]
),
),
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
from pyfragment.exceptions import FragmentAPIError
with pytest.raises(FragmentAPIError):
await client.purchase_stars("@user", amount=500)
@pytest.mark.asyncio
async def test_purchase_stars_need_verify_raises(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updateStarsBuyState
{"req_id": FAKE_REQ_ID},
{"need_verify": True}, # getBuyStarsLink
]
),
),
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
from pyfragment.exceptions import VerificationError
with pytest.raises(VerificationError):
await client.purchase_stars("@user", amount=500)
# Stars giveaway — error branches
@pytest.mark.asyncio
async def test_giveaway_stars_missing_req_id_raises(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updateStarsGiveawayState
{}, # updateStarsGiveawayPrices
{"amount": "0.1"}, # initGiveawayStarsRequest — no req_id
]
),
),
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
from pyfragment.exceptions import FragmentAPIError
with pytest.raises(FragmentAPIError):
await client.giveaway_stars("@channel", winners=3, amount=1000)
@pytest.mark.asyncio
async def test_giveaway_stars_need_verify_raises(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updateStarsGiveawayState
{}, # updateStarsGiveawayPrices
{"req_id": FAKE_REQ_ID},
{"need_verify": True}, # getGiveawayStarsLink
]
),
),
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
from pyfragment.exceptions import VerificationError
with pytest.raises(VerificationError):
await client.giveaway_stars("@channel", winners=3, amount=1000)
+135 -6
View File
@@ -7,6 +7,9 @@ import pytest
import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod
import pyfragment.domains.purchases.purchase as _purchase_premium_mod
from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError
from pyfragment.core.constants import PREMIUM_MONTHS_VALID, PREMIUM_WINNERS_MAX, PREMIUM_WINNERS_MIN
from pyfragment.enums import PaymentMethod
from pyfragment.exceptions import AlreadySubscribedError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Premium purchase validation tests
@@ -21,7 +24,7 @@ async def test_purchase_premium_invalid_months(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.purchase_premium("@user", months=0)
await client.purchase_premium("@user", months=min(PREMIUM_MONTHS_VALID) - 1)
@pytest.mark.asyncio
@@ -75,7 +78,7 @@ async def test_purchase_premium_passes_payment_method(client: FragmentClient) ->
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_premium_mod, "process_transaction", proc_mock),
):
await client.purchase_premium("@user", months=6, payment_method="usdt_ton")
await client.purchase_premium("@user", months=6, payment_method=PaymentMethod.USDT_GRAM)
init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initGiftPremiumRequest"
@@ -84,6 +87,25 @@ async def test_purchase_premium_passes_payment_method(client: FragmentClient) ->
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
@pytest.mark.asyncio
async def test_purchase_premium_already_subscribed_raises(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updatePremiumState
{"error": "This account is already subscribed to Telegram Premium."},
]
),
),
):
with pytest.raises(AlreadySubscribedError):
await client.purchase_premium("@user", months=6)
@pytest.mark.asyncio
@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"])
async def test_purchase_premium_accepts_query_formats(client: FragmentClient, query: str) -> None:
@@ -110,13 +132,13 @@ async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_premium("@channel", winners=0, months=3)
await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MIN - 1, months=3)
@pytest.mark.asyncio
async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_premium("@channel", winners=24_001, months=3)
await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MAX + 1, months=3)
@pytest.mark.asyncio
@@ -150,6 +172,7 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{},
{},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
@@ -173,6 +196,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) ->
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{},
{},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
@@ -183,9 +207,9 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) ->
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_giveaway_premium_mod, "process_transaction", proc_mock),
):
await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton")
await client.giveaway_premium("@channel", winners=10, months=6, payment_method=PaymentMethod.USDT_GRAM)
init_call = call_mock.await_args_list[2]
init_call = call_mock.await_args_list[3]
assert init_call.args[0] == "initGiveawayPremiumRequest"
assert init_call.args[1]["payment_method"] == "usdt_ton"
assert proc_mock.await_args is not None
@@ -210,3 +234,108 @@ async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> Non
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.giveaway_premium("@ghost", winners=1, months=3)
# Premium purchase — error branches
@pytest.mark.asyncio
async def test_purchase_premium_not_a_user_raises(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"error": "Please enter a username assigned to a user."})):
with pytest.raises(UserNotFoundError, match="does not belong"):
await client.purchase_premium("@channel", months=3)
@pytest.mark.asyncio
async def test_purchase_premium_missing_req_id_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import FragmentAPIError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updatePremiumState
{"amount": "0.1"}, # initGiftPremiumRequest — no req_id
]
),
),
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(FragmentAPIError):
await client.purchase_premium("@user", months=3)
@pytest.mark.asyncio
async def test_purchase_premium_need_verify_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import VerificationError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updatePremiumState
{"req_id": FAKE_REQ_ID},
{"need_verify": True}, # getGiftPremiumLink
]
),
),
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(VerificationError):
await client.purchase_premium("@user", months=3)
# Premium giveaway — error branches
@pytest.mark.asyncio
async def test_giveaway_premium_missing_req_id_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import FragmentAPIError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updatePremiumGiveawayState
{}, # updatePremiumGiveawayPrices
{"amount": "0.1"}, # initGiveawayPremiumRequest — no req_id
]
),
),
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(FragmentAPIError):
await client.giveaway_premium("@channel", winners=10, months=3)
@pytest.mark.asyncio
async def test_giveaway_premium_need_verify_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import VerificationError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updatePremiumGiveawayState
{}, # updatePremiumGiveawayPrices
{"req_id": FAKE_REQ_ID},
{"need_verify": True}, # getGiveawayPremiumLink
]
),
),
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(VerificationError):
await client.giveaway_premium("@channel", winners=10, months=3)
+65 -16
View File
@@ -1,39 +1,40 @@
"""Cover TON top-up through Telegram Ads, including recipient lookup and transaction building."""
"""Cover GRAM (ex TON) top-up through Telegram Ads, including recipient lookup and transaction building."""
from unittest.mock import AsyncMock, patch
import pytest
import pyfragment.domains.ads.tonup as _topup_ton_mod
import pyfragment.domains.ads.tonup as _topup_gram_mod
from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.core.constants import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Topup TON validation tests
# Topup GRAM (ex TON) validation tests
@pytest.mark.asyncio
async def test_topup_ton_amount_zero(client: FragmentClient) -> None:
async def test_topup_gram_amount_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=0)
await client.topup_gram("@user", amount=GRAM_TOPUP_MIN - 1)
@pytest.mark.asyncio
async def test_topup_ton_amount_too_high(client: FragmentClient) -> None:
async def test_topup_gram_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=1_000_000_001)
await client.topup_gram("@user", amount=GRAM_TOPUP_MAX + 1)
@pytest.mark.asyncio
async def test_topup_ton_float_amount(client: FragmentClient) -> None:
async def test_topup_gram_float_amount(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type]
await client.topup_gram("@user", amount=1.5) # type: ignore[arg-type]
# Topup TON mocked tests
# Topup GRAM (ex TON) mocked tests
@pytest.mark.asyncio
async def test_topup_ton_success(client: FragmentClient) -> None:
async def test_topup_gram_success(client: FragmentClient) -> None:
with (
patch.object(
client,
@@ -47,10 +48,10 @@ async def test_topup_ton_success(client: FragmentClient) -> None:
]
),
),
patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_topup_gram_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.topup_ton("@user", amount=10)
result = await client.topup_gram("@user", amount=10)
assert isinstance(result, AdsTopupResult)
assert result.transaction_id == FAKE_TX_HASH
@@ -59,7 +60,7 @@ async def test_topup_ton_success(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
async def test_topup_gram_user_not_found(client: FragmentClient) -> None:
with patch.object(
client,
"call",
@@ -71,4 +72,52 @@ async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
),
):
with pytest.raises(UserNotFoundError):
await client.topup_ton("@ghost", amount=10)
await client.topup_gram("@ghost", amount=10)
# topup_gram — error branches
@pytest.mark.asyncio
async def test_topup_gram_missing_req_id_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import FragmentAPIError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsTopupState
{"found": {"recipient": FAKE_RECIPIENT}},
{"amount": "0.1"}, # initAdsTopupRequest — no req_id
]
),
),
patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(FragmentAPIError):
await client.topup_gram("@user", amount=10)
@pytest.mark.asyncio
async def test_topup_gram_need_verify_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import VerificationError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsTopupState
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
{"need_verify": True}, # getAdsTopupLink
]
),
),
patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(VerificationError):
await client.topup_gram("@user", amount=10)
+108 -14
View File
@@ -1,30 +1,30 @@
"""Verify wallet inspection returns friendly TON and USDT balances from Tonapi."""
"""Verify wallet inspection returns friendly GRAM (ex TON) and USDT balances from Tonapi."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pyfragment import FragmentClient, WalletInfo
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOGRAM
# Wallet mocked tests (TON and USDT balances are returned separately)
# Wallet mocked tests (GRAM and USDT balances are returned separately)
@pytest.mark.asyncio
async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
mock_wallet = MagicMock()
mock_wallet.refresh = AsyncMock()
mock_wallet.balance = FAKE_BALANCE_NANOTON
mock_wallet.balance = FAKE_BALANCE_NANOGRAM
mock_wallet.state = MagicMock(value="active")
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with (
patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=12.3456)),
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=12.3456)),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
result = await client.get_wallet()
@@ -32,7 +32,7 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
assert isinstance(result, WalletInfo)
assert result.address == FAKE_ADDRESS
assert result.state == "active"
assert result.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
assert result.gram_balance == round(FAKE_BALANCE_NANOGRAM / 1_000_000_000, 4)
assert result.usdt_balance == 12.3456
@@ -45,16 +45,110 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with (
patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=0.0)),
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=0.0)),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
result = await client.get_wallet()
assert result.ton_balance == 0.0
assert result.gram_balance == 0.0
assert result.usdt_balance == 0.0
assert result.state == "uninit"
# _make_ton_client — provider selection
def test_make_ton_client_uses_toncenter_for_toncenter_provider(client: FragmentClient) -> None:
from tonutils.clients import ToncenterClient
from pyfragment.enums import ApiProvider
from pyfragment.services.tonapi.account import _make_ton_client
client.api_provider = ApiProvider.TONCENTER
result = _make_ton_client(client)
assert isinstance(result, ToncenterClient)
def test_make_ton_client_uses_tonapi_for_tonapi_provider(client: FragmentClient) -> None:
from tonutils.clients import TonapiClient
from pyfragment.enums import ApiProvider
from pyfragment.services.tonapi.account import _make_ton_client
client.api_provider = ApiProvider.TONAPI
result = _make_ton_client(client)
assert isinstance(result, TonapiClient)
# get_usdt_balance — error paths
@pytest.mark.asyncio
async def test_get_usdt_balance_non_404_provider_error_raises() -> None:
from tonutils.exceptions import ProviderResponseError
from pyfragment import WalletError
from pyfragment.services.tonapi.account import get_usdt_balance
ton = MagicMock()
err = ProviderResponseError(code=500, message="server error", endpoint="api.tonapi.io")
with patch("pyfragment.services.tonapi.account.get_wallet_address_get_method", AsyncMock(side_effect=err)):
with pytest.raises(WalletError):
await get_usdt_balance(ton, "0:abc")
@pytest.mark.asyncio
async def test_get_usdt_balance_generic_exception_raises() -> None:
from pyfragment import WalletError
from pyfragment.services.tonapi.account import get_usdt_balance
ton = MagicMock()
with patch(
"pyfragment.services.tonapi.account.get_wallet_address_get_method", AsyncMock(side_effect=RuntimeError("timeout"))
):
with pytest.raises(WalletError):
await get_usdt_balance(ton, "0:abc")
# get_account_info / get_wallet_info — exception paths
@pytest.mark.asyncio
async def test_get_account_info_exception_raises_wallet_error(client: FragmentClient) -> None:
from pyfragment import WalletError
from pyfragment.services.tonapi.account import get_account_info
with (
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.side_effect = RuntimeError("wallet init failed")
with pytest.raises(WalletError, match="account info"):
await get_account_info(client)
@pytest.mark.asyncio
async def test_get_wallet_info_exception_raises_wallet_error(client: FragmentClient) -> None:
from pyfragment import WalletError
from pyfragment.services.tonapi.account import get_wallet_info
with (
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.side_effect = RuntimeError("key derivation failed")
with pytest.raises(WalletError, match="wallet info"):
await get_wallet_info(client)
+3 -3
View File
@@ -2,8 +2,8 @@
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from curl_cffi.requests import AsyncSession, Response
from pyfragment import FragmentClient, FragmentPageError
from pyfragment.core.transport import fragment_request
@@ -67,10 +67,10 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_fragment_request_non_200_raises() -> None:
response = MagicMock(spec=httpx.Response)
response = MagicMock(spec=Response)
response.status_code = 429
session = AsyncMock(spec=httpx.AsyncClient)
session = AsyncMock(spec=AsyncSession)
session.post = AsyncMock(return_value=response)
with pytest.raises(FragmentPageError, match="429"):
+49 -2
View File
@@ -6,6 +6,7 @@ import pytest
import pyfragment.domains.ads.recharge as _recharge_ads_mod
from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient
from pyfragment.core.constants import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# recharge_ads validation tests
@@ -14,13 +15,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANS
@pytest.mark.asyncio
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0)
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MIN - 1)
@pytest.mark.asyncio
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001)
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MAX + 1)
@pytest.mark.asyncio
@@ -54,3 +55,49 @@ async def test_recharge_ads_success(client: FragmentClient) -> None:
assert isinstance(result, AdsRechargeResult)
assert result.transaction_id == FAKE_TX_HASH
assert result.amount == 10
# recharge_ads — error branches
@pytest.mark.asyncio
async def test_recharge_ads_missing_req_id_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import FragmentAPIError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsState
{"amount": "0.1"}, # initAdsRechargeRequest — no req_id
]
),
),
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(FragmentAPIError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
@pytest.mark.asyncio
async def test_recharge_ads_need_verify_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import VerificationError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsState
{"req_id": FAKE_REQ_ID},
{"need_verify": True}, # getAdsRechargeLink
]
),
),
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(VerificationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
+1 -1
View File
@@ -22,7 +22,7 @@ def _mock_rookiepy(jar: list[dict[str, str]] | None = None) -> MagicMock:
return mock
PATCH = "pyfragment.core.cookies.rookiepy"
PATCH = "pyfragment.services.cookies.service.rookiepy"
# unsupported browser tests
+2 -2
View File
@@ -10,8 +10,8 @@ import pyfragment.domains.ads.recharge # noqa: F401
import pyfragment.domains.ads.tonup # noqa: F401
import pyfragment.domains.giveaways.giveaway # noqa: F401
import pyfragment.domains.purchases.purchase # noqa: F401
import pyfragment.domains.tonapi.account # noqa: F401
import pyfragment.domains.tonapi.transaction # noqa: F401
import pyfragment.services.tonapi.account # noqa: F401
import pyfragment.services.tonapi.transaction # noqa: F401
from pyfragment import FragmentClient
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
+2 -6
View File
@@ -1,7 +1,7 @@
"""Shared test constants for the pyfragment test suite.
pyfragment is an async Python client for the Fragment API a unified toolkit
to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances,
to manage Telegram assets: purchase Stars and Premium, top up GRAM (ex TON) and Ads balances,
run giveaways, manage anonymous numbers, and explore the marketplace for usernames,
numbers, and gifts.
"""
@@ -31,15 +31,11 @@ FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}}
# get_wallet()
FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5"
FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON
FAKE_BALANCE_NANOGRAM: int = 1_500_000_000 # 1.5 GRAM (ex TON)
# recharge_ads
FAKE_ADS_ACCOUNT: str = "@mychannel"
# Revenue withdrawals
FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
FAKE_REVENUE_TX: str = "revenue_tx_abc123"
# Anonymous number
FAKE_HTML_WITH_CODE: str = """
<table>