Merge pull request #28 from bohd4nx/dev

Dev
This commit is contained in:
Bohdan 👾
2026-06-16 01:50:14 +03:00
committed by GitHub
71 changed files with 1335 additions and 577 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
with: with:
python-version: "3.12" python-version: "3.12"
- uses: astral-sh/setup-uv@v8.1.0 - uses: astral-sh/setup-uv@v8.2.0
- run: uv build - run: uv build
+4
View File
@@ -37,3 +37,7 @@ systests/
dist/ dist/
build/ build/
*.egg-info/ *.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 ## [2026.3.1] — 2026-05-29
### Added ### 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 - `py.typed` marker — full PEP 561 typing support for type-checkers
- `__repr__` on all result types for readable debug output - `__repr__` on all result types for readable debug output
[2026.3.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.3.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.0
[2026.2.3]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.3 [2026.2.3]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.3
[2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2 [2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2
+9 -7
View File
@@ -28,16 +28,18 @@ All three must pass before opening a PR.
``` ```
pyfragment/ pyfragment/
client.py — FragmentClient (public entry point) 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 domains/ — one package per feature domain
ads/ — recharge_ads, topup_ton ads/ — recharge_ads, topup_gram
anonymous_numbers/— buy_number, manage_number anonymous_numbers/— get_login_code, toggle_login_codes, terminate_sessions
giveaways/ — giveaway_stars, giveaway_premium giveaways/ — giveaway_stars, giveaway_premium
marketplace/ — search_usernames, search_numbers, search_gifts marketplace/ — search_usernames, search_numbers, search_gifts
purchases/ — purchase_stars, purchase_premium purchases/ — purchase_stars, purchase_premium
tonapi/ — wallet info, transaction signing services/ — shared infrastructure services
models/ result dataclasses and enums cookies/browser cookie extraction (models + service)
exceptions.pyexception hierarchy tonapi/ wallet info, transaction signing (tonapi/toncenter)
tests/ — unit tests (pytest) tests/ — unit tests (pytest)
examples/ — runnable usage examples (excluded from CI) examples/ — runnable usage examples (excluded from CI)
``` ```
@@ -46,7 +48,7 @@ examples/ — runnable usage examples (excluded from CI)
- All public async methods live on `FragmentClient` and delegate to a domain service. - All public async methods live on `FragmentClient` and delegate to a domain service.
- Domain functions receive a `FragmentClient` instance, never raw httpx clients. - Domain functions receive a `FragmentClient` instance, never raw httpx clients.
- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.domains.tonapi.transaction.process_transaction`. - Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.services.tonapi.transaction._make_ton_client`.
- Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`. - Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`.
## Pull requests ## Pull requests
+21 -16
View File
@@ -10,10 +10,10 @@
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE) [![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. Async Python client for the **[Fragment](https://fragment.com)** marketplace API.
Buy Stars & Premium, run giveaways, top up TON and Ads balances, Buy Stars & Premium, run giveaways, top up GRAM (ex TON) and Ads balances,
manage anonymous numbers, and search Fragment listings. manage anonymous numbers, and search Fragment listings.
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate TON](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)** **[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate GRAM](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)**
</div> </div>
@@ -38,29 +38,33 @@ pip install git+https://github.com/bohd4nx/pyfragment.git@dev
```python ```python
import asyncio import asyncio
from pyfragment import FragmentClient from pyfragment import FragmentClient
from pyfragment.enums import PaymentMethod
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(
seed="word1 word2 ... word24", seed="word1 word2 ... word24",
api_key="YOUR_TONAPI_KEY", api_key="YOUR_API_KEY", # tonconsole.com (tonapi, default) or t.me/toncenter
cookies={ cookies={
"stel_ssid": "...", "stel_ssid": "...",
"stel_dt": "...", "stel_dt": "...",
"stel_token": "...", "stel_token": "...",
"stel_ton_token": "...", "stel_ton_token": "...",
}, },
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client: ) as client:
wallet = await client.get_wallet() wallet = await client.get_wallet()
print(f"TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}") print(f"GRAM: {wallet.gram_balance} | USDT: {wallet.usdt_balance}")
recipient = "https://t.me/username" # also: @username, username recipient = "https://t.me/username" # also: @username, username
stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton") stars = await client.purchase_stars(recipient, amount=500, payment_method=PaymentMethod.USDT_GRAM)
print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}") print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}")
premium = await client.purchase_premium(recipient, months=6, payment_method="ton") premium = await client.purchase_premium(recipient, months=6, payment_method=PaymentMethod.GRAM)
print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}") print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}")
@@ -71,13 +75,14 @@ asyncio.run(main())
## Configuration ## Configuration
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
| ---------------- | ------------- | -------- | ----------------------------------------------------------- | | ---------------- | ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- |
| `seed` | `str` | — | 12- or 24-word TON wallet mnemonic | | `seed` | `str` | — | 12- or 24-word GRAM (ex TON) wallet mnemonic |
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) | | `api_key` | `str` | — | API key for the chosen provider (see `api_provider`) |
| `cookies` | `dict \| str` | — | Fragment session cookies | | `cookies` | `dict \| str` | — | Fragment session cookies |
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal | | `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds | | `api_provider` | `str` | `"tonapi"` | `"tonapi"` ([tonconsole.com](https://tonconsole.com)) or `"toncenter"` ([t.me/toncenter](https://t.me/toncenter)) |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
--- ---
@@ -85,7 +90,7 @@ asyncio.run(main())
### Fragment cookies ### Fragment cookies
Log in to [fragment.com](https://fragment.com) and connect your TON wallet. Log in to [fragment.com](https://fragment.com) and connect your GRAM (ex TON) wallet.
**Automatically** (recommended) — reads directly from your browser, no extension needed: **Automatically** (recommended) — reads directly from your browser, no extension needed:
@@ -111,7 +116,7 @@ Generate at [tonconsole.com](https://tonconsole.com).
### Seed phrase ### Seed phrase
12- or 24-word mnemonic from your TON wallet (**Tonkeeper → Settings → Backup**). Never share it. 12- or 24-word mnemonic from your GRAM (ex TON) wallet (**Tonkeeper → Settings → Backup**). Never share it.
--- ---
@@ -136,7 +141,7 @@ All exceptions inherit from `FragmentError`:
from pyfragment import ( from pyfragment import (
ConfigurationError, # invalid arguments (amount, months, payment_method…) ConfigurationError, # invalid arguments (amount, months, payment_method…)
UserNotFoundError, # recipient not found on Fragment UserNotFoundError, # recipient not found on Fragment
WalletError, # insufficient TON or USDT balance WalletError, # insufficient GRAM (ex TON) or USDT balance
TransactionError, # broadcast failed, duplicate seqno, invalid payload TransactionError, # broadcast failed, duplicate seqno, invalid payload
FragmentAPIError, # Fragment API returned an error response FragmentAPIError, # Fragment API returned an error response
FragmentPageError, # page fetch or hash extraction failed FragmentPageError, # page fetch or hash extraction failed
+1 -1
View File
@@ -16,4 +16,4 @@ You will receive a response within 72 hours. Once the fix is released, the advis
## Scope ## 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 from pyfragment import FragmentClient, GiftsResult
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # 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 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) result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):") print(f"Found {len(result.items)} result(s):")
+8 -2
View File
@@ -12,7 +12,7 @@ import json
from pyfragment import FragmentClient, NumbersResult from pyfragment import FragmentClient, NumbersResult
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # 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 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) result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):") print(f"Found {len(result.items)} result(s):")
+8 -2
View File
@@ -12,7 +12,7 @@ import json
from pyfragment import FragmentClient, UsernamesResult from pyfragment import FragmentClient, UsernamesResult
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # 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 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) result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):") 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 from pyfragment import FragmentClient
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # 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 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) result = await client.call(METHOD, DATA, page_url=PAGE_URL)
print(result) 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. Cookies can be passed as a dict or as a JSON string.
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets. 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 import asyncio
@@ -10,7 +11,7 @@ import asyncio
from pyfragment import FragmentClient from pyfragment import FragmentClient
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -29,12 +30,13 @@ async def main() -> None:
seed=SEED, seed=SEED,
api_key=API_KEY, api_key=API_KEY,
cookies=COOKIES, cookies=COOKIES,
wallet_version="V5R1", # or "V4R2" wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client: ) as client:
wallet = await client.get_wallet() wallet = await client.get_wallet()
print(f"Address: {wallet.address}") print(f"Address: {wallet.address}")
print(f"State: {wallet.state}") 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") print(f"Balance: {wallet.usdt_balance} USDT")
+8 -2
View File
@@ -11,7 +11,7 @@ import asyncio
from pyfragment import AnonymousNumberError, FragmentClient from pyfragment import AnonymousNumberError, FragmentClient
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -28,7 +28,13 @@ NUMBER = "+88888888888"
async def main() -> None: 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 # Fetch the latest login code
result = await client.get_login_code(NUMBER) result = await client.get_login_code(NUMBER)
if result.code: 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. winners must be an integer between 1 and 24 000.
months (Premium duration per winner) must be 3, 6, or 12. 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". Channel can be "@channel", "channel", or "https://t.me/channel".
""" """
import asyncio import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -28,11 +28,17 @@ COOKIES = {
CHANNEL = "https://t.me/channel" CHANNEL = "https://t.me/channel"
WINNERS = 10 # 124 000 WINNERS = 10 # 124 000
MONTHS = 3 # 3, 6 or 12 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 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: try:
result = await client.giveaway_premium( result = await client.giveaway_premium(
CHANNEL, CHANNEL,
@@ -3,16 +3,16 @@ Example: purchase Telegram Premium for a user.
Supported durations: 3, 6, or 12 months. Supported durations: 3, 6, or 12 months.
Set show_sender=False to send anonymously. 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". Username can be "@username", "username", or "https://t.me/username".
""" """
import asyncio import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -27,11 +27,17 @@ COOKIES = {
USERNAME = "https://t.me/username" USERNAME = "https://t.me/username"
MONTHS = 3 # 3, 6 or 12 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 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: try:
result = await client.purchase_premium( result = await client.purchase_premium(
USERNAME, USERNAME,
@@ -1,18 +1,18 @@
""" """
Example: purchase Telegram Stars for a user. 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. 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". Username can be "@username", "username", or "https://t.me/username".
""" """
import asyncio import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -26,12 +26,18 @@ COOKIES = {
} }
USERNAME = "https://t.me/username" USERNAME = "https://t.me/username"
AMOUNT = 500 # 501 000 000 stars AMOUNT = 500 # 5010 000 000 stars
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 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: try:
result = await client.purchase_stars( result = await client.purchase_stars(
USERNAME, 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. Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON).
Your wallet must satisfy the current minimum TON threshold and transaction cost. Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost.
""" """
import asyncio import asyncio
@@ -15,7 +15,7 @@ from pyfragment import (
) )
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # 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 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 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: try:
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT) result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
except WalletError as e: except WalletError as e:
@@ -43,7 +49,7 @@ async def main() -> None:
print(f"Invalid argument: {e}") print(f"Invalid argument: {e}")
return 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__": if __name__ == "__main__":
@@ -1,18 +1,18 @@
""" """
Example: run a Telegram Stars giveaway for a channel. 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. 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". Channel can be "@channel", "channel", or "https://t.me/channel".
""" """
import asyncio import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -26,13 +26,19 @@ COOKIES = {
} }
CHANNEL = "https://t.me/channel" CHANNEL = "https://t.me/channel"
WINNERS = 3 # 15 WINNERS = 3 # 115
AMOUNT = 1000 # 5001 000 000 stars per winner 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 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: try:
result = await client.giveaway_stars( result = await client.giveaway_stars(
CHANNEL, 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. Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON).
Your wallet must satisfy the current minimum TON threshold and transaction cost. Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost.
""" """
import asyncio import asyncio
@@ -17,7 +17,7 @@ from pyfragment import (
) )
SEED = "word1 word2 ... word24" 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) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -31,13 +31,19 @@ COOKIES = {
} }
USERNAME = "@username" USERNAME = "@username"
AMOUNT = 10 # 11 000 000 000 TON AMOUNT = 10 # 11 000 000 000 GRAM (ex TON)
async def main() -> None: 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: 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: except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return return
@@ -48,7 +54,7 @@ async def main() -> None:
print(f"Invalid argument: {e}") print(f"Invalid argument: {e}")
return 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__": if __name__ == "__main__":
+11 -8
View File
@@ -2,8 +2,14 @@ import logging
from importlib.metadata import version from importlib.metadata import version
from pyfragment.client import FragmentClient 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 ( from pyfragment.exceptions import (
AlreadySubscribedError,
AnonymousNumberError, AnonymousNumberError,
ClientError, ClientError,
ConfigurationError, ConfigurationError,
@@ -19,13 +25,8 @@ from pyfragment.exceptions import (
VerificationError, VerificationError,
WalletError, WalletError,
) )
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult from pyfragment.services.cookies import CookieResult, get_cookies_from_browser
from pyfragment.models.cookies import CookieResult from pyfragment.services.tonapi.models import WalletInfo
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
logging.getLogger("pyfragment").addHandler(logging.NullHandler()) logging.getLogger("pyfragment").addHandler(logging.NullHandler())
@@ -53,6 +54,7 @@ __all__ = [
"FragmentAPIError", "FragmentAPIError",
"FragmentPageError", "FragmentPageError",
"ConfigurationError", "ConfigurationError",
"AlreadySubscribedError",
"UserNotFoundError", "UserNotFoundError",
"WalletError", "WalletError",
"VerificationError", "VerificationError",
@@ -64,6 +66,7 @@ __all__ = [
"ParseError", "ParseError",
"UnexpectedError", "UnexpectedError",
# literal types # literal types
"ApiProvider",
"PaymentMethod", "PaymentMethod",
"WalletVersion", "WalletVersion",
"get_cookies_from_browser", "get_cookies_from_browser",
+77 -132
View File
@@ -1,23 +1,29 @@
from __future__ import annotations from __future__ import annotations
import json from typing import Any
from typing import Any, cast, get_args
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.ads.service import AdsService
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
from pyfragment.domains.base import raw_api_call 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.giveaways.service import GiveawaysService
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.domains.marketplace.service import MarketplaceService 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.purchases.service import PurchasesService
from pyfragment.domains.tonapi.service import TonapiService from pyfragment.enums import ApiProvider, PaymentMethod, WalletVersion
from pyfragment.exceptions import ConfigurationError, CookieError from pyfragment.services.tonapi.models import WalletInfo
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult from pyfragment.services.tonapi.service import TonapiService
from pyfragment.models.enums import PaymentMethod, WalletVersion
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import WalletInfo
class FragmentClient: class FragmentClient:
@@ -29,14 +35,18 @@ class FragmentClient:
connected with Fragment or Telegram. connected with Fragment or Telegram.
Args: Args:
seed: 12- or 24-word mnemonic phrase for the TON wallet. seed: 12- or 24-word mnemonic phrase for the GRAM (ex TON) wallet.
api_key: Tonapi API key — get one at https://tonconsole.com. 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. cookies: Fragment session cookies as a dict or JSON string.
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). 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``. timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
headers: Custom HTTP request headers. If omitted, :data:`BASE_HEADERS` is used.
Raises: 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. CookieError: If ``cookies`` cannot be parsed or are missing required keys.
Example:: Example::
@@ -51,63 +61,29 @@ class FragmentClient:
print(result.transaction_id) 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__( def __init__(
self, self,
seed: str, seed: str,
api_key: str, api_key: str,
cookies: dict[str, Any] | str, cookies: dict[str, Any] | str,
wallet_version: str = "V5R1", wallet_version: str = "V5R1",
api_provider: str = "tonapi",
timeout: float = DEFAULT_TIMEOUT, timeout: float = DEFAULT_TIMEOUT,
headers: dict[str, str] | None = None,
) -> None: ) -> None:
self._validate_required(seed, api_key) validate_credentials(seed, api_key)
parsed_cookies = self._parse_cookies(cookies) provider = normalize_provider(api_provider)
self._validate_cookie_keys(parsed_cookies) parsed_cookies = parse_cookies(cookies)
version = self._normalize_wallet_version(wallet_version) validate_cookie_keys(parsed_cookies)
version = normalize_wallet_version(wallet_version)
self.seed: str = seed.strip() self.seed: str = seed.strip()
self.api_key: str = api_key.strip() self.api_key: str = api_key.strip()
self.api_provider: ApiProvider = provider
self.cookies: dict[str, Any] = parsed_cookies self.cookies: dict[str, Any] = parsed_cookies
self.wallet_version: WalletVersion = version self.wallet_version: WalletVersion = version
self.timeout: float = timeout self.timeout: float = timeout
self.headers: dict[str, str] = headers if headers is not None else BASE_HEADERS
self.marketplace = MarketplaceService(self) self.marketplace = MarketplaceService(self)
self.purchases = PurchasesService(self) self.purchases = PurchasesService(self)
self.giveaways = GiveawaysService(self) self.giveaways = GiveawaysService(self)
@@ -122,14 +98,14 @@ class FragmentClient:
pass pass
def __repr__(self) -> str: 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( async def purchase_premium(
self, self,
username: str, username: str,
months: int, months: int,
show_sender: bool = True, show_sender: bool = True,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumResult: ) -> PremiumResult:
"""Gift Telegram Premium to a user. """Gift Telegram Premium to a user.
@@ -137,7 +113,7 @@ class FragmentClient:
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
months: Duration — ``3``, ``6``, or ``12``. months: Duration — ``3``, ``6``, or ``12``.
show_sender: Show your name as the sender. Defaults to ``True``. 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: Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
@@ -149,41 +125,40 @@ class FragmentClient:
username: str, username: str,
amount: int, amount: int,
show_sender: bool = True, show_sender: bool = True,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsResult: ) -> StarsResult:
"""Send Telegram Stars to a user. """Send Telegram Stars to a user.
Args: Args:
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. 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``. 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: Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
""" """
return await self.purchases.purchase_stars(username, amount, show_sender=show_sender, payment_method=payment_method) return await self.purchases.purchase_stars(username, amount, show_sender=show_sender, payment_method=payment_method)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up TON to a recipient's Telegram balance. """Top up GRAM (ex TON) to a recipient's Telegram balance.
Args: Args:
username: Recipient's Telegram username (with or without ``@``). 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``. show_sender: Show your name as the sender. Defaults to ``True``.
Returns: Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``. :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: async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account. """Add funds to your own Telegram Ads account.
Args: Args:
account: Your Fragment Ads account identifier — the channel or bot username account: Channel or bot username the Ads account is linked to (e.g. ``"@mychannel"``).
the Ads account is linked to (e.g. ``"@mychannel"``). amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``.
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
Returns: Returns:
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``. :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
@@ -194,9 +169,7 @@ class FragmentClient:
"""Return the address, state, and balances of the wallet. """Return the address, state, and balances of the wallet.
Returns: Returns:
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state`` :class:`WalletInfo` with ``address``, ``state``, ``gram_balance``, and ``usdt_balance``.
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
``ton_balance`` in TON, and ``usdt_balance`` in USDT.
""" """
return await self.tonapi.get_wallet() return await self.tonapi.get_wallet()
@@ -205,19 +178,18 @@ class FragmentClient:
channel: str, channel: str,
winners: int, winners: int,
amount: int, amount: int,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsGiveawayResult: ) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel. """Run a Telegram Stars giveaway for a channel.
Args: Args:
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. 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``. 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: Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``.
``winners``, and ``amount``.
""" """
return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method) return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method)
@@ -226,19 +198,18 @@ class FragmentClient:
channel: str, channel: str,
winners: int, winners: int,
months: int = 3, months: int = 3,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumGiveawayResult: ) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel. """Run a Telegram Premium giveaway for a channel.
Args: Args:
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. 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``. 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: Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``.
``winners``, and ``amount``.
""" """
return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method) 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. """Fetch the current pending login code for an anonymous number.
Args: Args:
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``). number: Phone number with or without leading ``+``.
Returns: Returns:
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending), :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
@@ -273,7 +244,7 @@ class FragmentClient:
:class:`TerminateSessionsResult` with ``number`` and ``message``. :class:`TerminateSessionsResult` with ``number`` and ``message``.
Raises: 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) return await self.anonymous_numbers.terminate_sessions(number)
@@ -287,17 +258,13 @@ class FragmentClient:
"""Search the Fragment marketplace for Telegram usernames. """Search the Fragment marketplace for Telegram usernames.
Args: Args:
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all. query: Search text. Omit or pass ``""`` to browse all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
``"ending"``. Omit to use Fragment's default ordering. filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or offset_id: Pass :attr:`UsernamesResult.next_offset_id` to fetch the next page.
``""`` (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.
Returns: Returns:
:class:`UsernamesResult` with ``items`` (parsed list of item dicts) :class:`UsernamesResult` with ``items`` and ``next_offset_id``.
and ``next_offset_id`` (``None`` on the last page).
""" """
return await self.marketplace.search_usernames(query, sort=sort, filter=filter, offset_id=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. """Search the Fragment marketplace for anonymous Telegram numbers.
Args: Args:
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all. query: Search text. Omit or pass ``""`` to browse all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
``"ending"``. Omit to use Fragment's default ordering. filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or offset_id: Pass :attr:`NumbersResult.next_offset_id` to fetch the next page.
``""`` (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.
Returns: Returns:
:class:`NumbersResult` with ``items`` (parsed list of item dicts) :class:`NumbersResult` with ``items`` and ``next_offset_id``.
and ``next_offset_id`` (``None`` on the last page).
""" """
return await self.marketplace.search_numbers(query, sort=sort, filter=filter, offset_id=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. """Search the Fragment gifts marketplace.
Args: Args:
query: Search text. Omit or pass ``""`` to browse without filtering by name. query: Search text. Omit or pass ``""`` to browse all.
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all. collection: Gift collection slug (e.g. ``"artisanbrick"``).
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
``"ending"``. Omit to use Fragment's default ordering. filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or view: Active attribute tab name (e.g. ``"Model"``).
``""`` (available items). Omit to return all. attr: Attribute filters — e.g. ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}``.
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default. offset: Pass :attr:`GiftsResult.next_offset` to fetch the next page.
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.
Returns: Returns:
:class:`GiftsResult` with ``items`` (parsed list of item dicts) :class:`GiftsResult` with ``items`` and ``next_offset``.
and ``next_offset`` (``None`` on the last page).
""" """
return await self.marketplace.search_gifts( return await self.marketplace.search_gifts(
query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
@@ -364,24 +321,12 @@ class FragmentClient:
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Send a raw request to the Fragment API. """Send a raw request to the Fragment API.
Useful for accessing undocumented or future Fragment API methods
without waiting for a library update.
Args: Args:
method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``. method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``.
data: Additional form-data fields to include in the request body. data: Additional form-data fields.
page_url: Fragment page URL used to derive the API hash and headers. page_url: Fragment page URL to derive the API hash. Defaults to ``FRAGMENT_BASE_URL``.
Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``).
Returns: Returns:
Raw parsed JSON response as a dict. 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)
+50 -43
View File
@@ -1,22 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json
from typing import Any 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_DOMAIN: str = "fragment.com"
FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}" FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}"
STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway"
PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" 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" NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts"
SUPPORTED_BROWSERS: frozenset[str] = frozenset( DEFAULT_TIMEOUT: float = 30.0
{
"arc",
"brave",
"chrome",
"chromium",
"chromium_based",
"edge",
"firefox",
"firefox_based",
"librewolf",
"opera",
"opera_gx",
"safari",
"vivaldi",
}
)
DEVICE: str = json.dumps( # Fragment cookie keys required for authenticated API calls
{ REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
"platform": "iphone",
"appName": "Tonkeeper",
"appVersion": "26.04.0",
"maxProtocolVersion": 2,
"features": [
"SendTransaction",
{"name": "SendTransaction", "maxMessages": 255},
{"name": "SignData", "types": ["text", "binary", "cell"]},
],
}
)
BASE_HEADERS: dict[str, str] = { BASE_HEADERS: dict[str, str] = {
"accept": "application/json, text/javascript, */*; q=0.01", "accept": "application/json, text/javascript, */*; q=0.01",
@@ -75,3 +36,49 @@ BASE_HEADERS: dict[str, str] = {
), ),
"x-requested-with": "XMLHttpRequest", "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})
+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.recharge import recharge_ads
from pyfragment.domains.ads.service import AdsService 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 import logging
from typing import TYPE_CHECKING 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.tonapi.account import get_account_info from pyfragment.domains.ads.models import AdsRechargeResult
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError 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: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
@@ -18,8 +18,8 @@ logger = logging.getLogger(__name__)
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult: async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT)
try: try:
await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE) 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", "getAdsRechargeLink",
{ {
"account": json.dumps(account_info), "account": json.dumps(account_info),
"device": DEVICE, "device": json.dumps(DEVICE_INFO),
"transaction": 1, "transaction": 1,
"id": req_id, "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) return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
except FragmentError as exc: 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 raise
except Exception as exc: 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 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 typing import TYPE_CHECKING
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult
from pyfragment.domains.ads.recharge import recharge_ads 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.domains.base import BaseService
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -15,5 +15,5 @@ class AdsService(BaseService):
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
return await recharge_ads(self._client, account, amount) return await recharge_ads(self._client, account, amount)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
return await topup_ton(self._client, username, amount, show_sender=show_sender) return await topup_gram(self._client, username, amount, show_sender=show_sender)
+15 -11
View File
@@ -4,10 +4,9 @@ import json
import logging import logging
from typing import TYPE_CHECKING 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.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 ( from pyfragment.exceptions import (
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
@@ -16,7 +15,8 @@ from pyfragment.exceptions import (
UserNotFoundError, UserNotFoundError,
VerificationError, 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: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
@@ -25,9 +25,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: async def topup_gram(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT)
try: try:
await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE) 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) required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id") req_id = result.get("req_id")
if not req_id: if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="GRAM (ex TON) topup"))
account = await get_account_info(client) account = await get_account_info(client)
transaction = await client.call( transaction = await client.call(
"getAdsTopupLink", "getAdsTopupLink",
{ {
"account": json.dumps(account), "account": json.dumps(account),
"device": DEVICE, "device": json.dumps(DEVICE_INFO),
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
"show_sender": int(show_sender), "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) return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError as exc: 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 raise
except Exception as exc: 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 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.number import get_login_code, terminate_sessions, toggle_login_codes
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
__all__ = [ __all__ = [
"AnonymousNumbersService", "AnonymousNumbersService",
@@ -21,6 +21,3 @@ class TerminateSessionsResult:
def __repr__(self) -> str: def __repr__(self) -> str:
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})" 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 typing import TYPE_CHECKING
from pyfragment.core.constants import NUMBERS_PAGE 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.domains.anonymous_numbers.parser import parse_login_code
from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
@@ -2,9 +2,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING 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.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes
from pyfragment.domains.base import BaseService from pyfragment.domains.base import BaseService
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
+5 -3
View File
@@ -20,14 +20,16 @@ async def raw_api_call(
method: str, method: str,
data: dict[str, Any] | None, data: dict[str, Any] | None,
page_url: str, page_url: str,
headers: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
base = headers if headers is not None else BASE_HEADERS
payload = {"method": method, **(data or {})} 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) logger.debug("Starting Fragment API call '%s' on %s", method, page_url)
try: try:
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session: async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
fragment_hash = await get_fragment_hash(cookies, headers, page_url, timeout) fragment_hash = await get_fragment_hash(cookies, call_headers, page_url, timeout)
response = await fragment_request(session, fragment_hash, headers, payload) 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())) logger.debug("Completed Fragment API call '%s' with response keys: %s", method, sorted(response.keys()))
return response return response
except Exception: except Exception:
+1 -1
View File
@@ -1,6 +1,6 @@
from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars 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.domains.giveaways.service import GiveawaysService
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
__all__ = [ __all__ = [
"GiveawaysService", "GiveawaysService",
+46 -20
View File
@@ -3,12 +3,23 @@ from __future__ import annotations
import json import json
import logging import logging
import random 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.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.account import get_account_info from pyfragment.enums import PaymentMethod
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import ( from pyfragment.exceptions import (
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
@@ -17,8 +28,8 @@ from pyfragment.exceptions import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
) )
from pyfragment.models.enums import PaymentMethod from pyfragment.services.tonapi.account import get_account_info
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
@@ -27,22 +38,27 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) 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( async def giveaway_stars(
client: FragmentClient, client: FragmentClient,
channel: str, channel: str,
winners: int, winners: int,
amount: int, amount: int,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsGiveawayResult: ) -> 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) 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) 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( raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format( ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method, 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( await client.call(
"updateStarsGiveawayState", "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, page_url=STARS_GIVEAWAY_PAGE,
) )
@@ -78,7 +99,7 @@ async def giveaway_stars(
"getGiveawayStarsLink", "getGiveawayStarsLink",
{ {
"account": json.dumps(account), "account": json.dumps(account),
"device": DEVICE, "device": json.dumps(DEVICE_INFO),
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
}, },
@@ -122,17 +143,17 @@ async def giveaway_premium(
channel: str, channel: str,
winners: int, winners: int,
months: int = 3, months: int = 3,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumGiveawayResult: ) -> 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) 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) 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( raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format( ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method, 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", "mode": "new",
"lv": "false", "lv": "false",
"dh": str(random.randint(100_000_000, 999_999_999)), "dh": _state_nonce(),
"quantity": "", "quantity": "",
}, },
page_url=PREMIUM_GIVEAWAY_PAGE, page_url=PREMIUM_GIVEAWAY_PAGE,
) )
await client.call(
"updatePremiumGiveawayPrices",
{"quantity": winners},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
result = await client.call( result = await client.call(
"initGiveawayPremiumRequest", "initGiveawayPremiumRequest",
@@ -177,7 +203,7 @@ async def giveaway_premium(
"getGiveawayPremiumLink", "getGiveawayPremiumLink",
{ {
"account": json.dumps(account), "account": json.dumps(account),
"device": DEVICE, "device": json.dumps(DEVICE_INFO),
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
}, },
@@ -29,6 +29,3 @@ class PremiumGiveawayResult:
f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, " f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, "
f"amount={self.amount} months per winner, tx='{self.transaction_id}')" 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.base import BaseService
from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
from pyfragment.models.enums import PaymentMethod from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult from pyfragment.enums import PaymentMethod
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -17,7 +17,7 @@ class GiveawaysService(BaseService):
channel: str, channel: str,
winners: int, winners: int,
amount: int, amount: int,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsGiveawayResult: ) -> StarsGiveawayResult:
return await giveaway_stars(self._client, channel, winners, amount, payment_method=payment_method) return await giveaway_stars(self._client, channel, winners, amount, payment_method=payment_method)
@@ -26,6 +26,6 @@ class GiveawaysService(BaseService):
channel: str, channel: str,
winners: int, winners: int,
months: int = 3, months: int = 3,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumGiveawayResult: ) -> PremiumGiveawayResult:
return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method) 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.search import search_gifts, search_numbers, search_usernames
from pyfragment.domains.marketplace.service import MarketplaceService from pyfragment.domains.marketplace.service import MarketplaceService
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
__all__ = [ __all__ = [
"GiftsResult", "GiftsResult",
@@ -29,6 +29,3 @@ class GiftsResult:
def __repr__(self) -> str: def __repr__(self) -> str:
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})" 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 typing import TYPE_CHECKING, Any
from pyfragment.core.constants import FRAGMENT_BASE_URL, GIFTS_PAGE, NUMBERS_PAGE 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.domains.marketplace.parser import parse_auction_rows, parse_gift_items
from pyfragment.exceptions import FragmentAPIError, FragmentError, UnexpectedError from pyfragment.exceptions import FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
+1 -1
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService 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.domains.marketplace.search import search_gifts, search_numbers, search_usernames
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
if TYPE_CHECKING: if TYPE_CHECKING:
pass 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.purchase import purchase_premium, purchase_stars
from pyfragment.domains.purchases.service import PurchasesService from pyfragment.domains.purchases.service import PurchasesService
from pyfragment.models.payments import PremiumResult, StarsResult
__all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"] __all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"]
@@ -21,25 +21,3 @@ class StarsResult:
def __repr__(self) -> str: def __repr__(self) -> str:
return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')" 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 json
import logging import logging
import time import random
from typing import TYPE_CHECKING, get_args from typing import TYPE_CHECKING
from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE from pyfragment.core.constants import (
DEVICE_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.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.purchases.models import PremiumResult, StarsResult
from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.enums import PaymentMethod
from pyfragment.exceptions import ( from pyfragment.exceptions import (
AlreadySubscribedError,
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
FragmentError, FragmentError,
@@ -17,8 +25,8 @@ from pyfragment.exceptions import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
) )
from pyfragment.models.enums import PaymentMethod from pyfragment.services.tonapi.account import get_account_info
from pyfragment.models.payments import PremiumResult, StarsResult from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
@@ -27,32 +35,39 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) 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( async def purchase_stars(
client: FragmentClient, client: FragmentClient,
username: str, username: str,
amount: int, amount: int,
show_sender: bool = True, show_sender: bool = True,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsResult: ) -> 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) 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( raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format( ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method, method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))), supported=", ".join(sorted(m.value for m in PaymentMethod)),
) )
) )
try: try:
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE) 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") recipient = result.get("found", {}).get("recipient")
if not recipient: if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
await client.call( await client.call(
"updateStarsBuyState", "updateStarsBuyState",
{"mode": "new", "lv": "false", "dh": str(int(time.time()))}, {"mode": "new", "lv": "false", "dh": _state_nonce()},
page_url=STARS_PAGE, page_url=STARS_PAGE,
) )
result = await client.call( result = await client.call(
@@ -70,7 +85,7 @@ async def purchase_stars(
"getBuyStarsLink", "getBuyStarsLink",
{ {
"account": json.dumps(account), "account": json.dumps(account),
"device": DEVICE, "device": json.dumps(DEVICE_INFO),
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
"show_sender": int(show_sender), "show_sender": int(show_sender),
@@ -113,27 +128,29 @@ async def purchase_premium(
username: str, username: str,
months: int, months: int,
show_sender: bool = True, show_sender: bool = True,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumResult: ) -> PremiumResult:
if months not in (3, 6, 12): if months not in PREMIUM_MONTHS_VALID:
raise ConfigurationError(ConfigurationError.INVALID_MONTHS) 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( raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format( ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method, method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))), supported=", ".join(sorted(m.value for m in PaymentMethod)),
) )
) )
try: try:
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE) 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") recipient = result.get("found", {}).get("recipient")
if not recipient: if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
await client.call( await client.call(
"updatePremiumState", "updatePremiumState",
{"mode": "new", "lv": "false", "dh": str(int(time.time()))}, {"mode": "new", "lv": "false", "dh": _state_nonce()},
page_url=PREMIUM_PAGE, page_url=PREMIUM_PAGE,
) )
result = await client.call( result = await client.call(
@@ -141,6 +158,9 @@ async def purchase_premium(
{"recipient": recipient, "months": months, "payment_method": payment_method}, {"recipient": recipient, "months": months, "payment_method": payment_method},
page_url=PREMIUM_PAGE, 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) required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id") req_id = result.get("req_id")
if not req_id: if not req_id:
@@ -151,7 +171,7 @@ async def purchase_premium(
"getGiftPremiumLink", "getGiftPremiumLink",
{ {
"account": json.dumps(account), "account": json.dumps(account),
"device": DEVICE, "device": json.dumps(DEVICE_INFO),
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
"show_sender": int(show_sender), "show_sender": int(show_sender),
+4 -4
View File
@@ -3,9 +3,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService 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.domains.purchases.purchase import purchase_premium, purchase_stars
from pyfragment.models.enums import PaymentMethod from pyfragment.enums import PaymentMethod
from pyfragment.models.payments import PremiumResult, StarsResult
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -17,7 +17,7 @@ class PurchasesService(BaseService):
username: str, username: str,
amount: int, amount: int,
show_sender: bool = True, show_sender: bool = True,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsResult: ) -> StarsResult:
return await purchase_stars(self._client, username, amount, show_sender=show_sender, payment_method=payment_method) 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, username: str,
months: int, months: int,
show_sender: bool = True, show_sender: bool = True,
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumResult: ) -> PremiumResult:
return await purchase_premium(self._client, username, months, show_sender=show_sender, payment_method=payment_method) 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",
]
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import sys
from enum import Enum
from typing import Any
from tonutils.contracts.wallet import WalletHighloadV2, WalletHighloadV3R1, WalletV4R2, WalletV5R1
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
class StrEnum(str, Enum): # noqa: F811
pass
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"
+48 -17
View File
@@ -1,5 +1,20 @@
from __future__ import annotations 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): class FragmentError(Exception):
"""Base exception for all pyfragment errors.""" """Base exception for all pyfragment errors."""
@@ -14,20 +29,26 @@ class ConfigurationError(ClientError):
MISSING_VARS = "Missing required parameter(s): {keys}." MISSING_VARS = "Missing required parameter(s): {keys}."
UNSUPPORTED_VERSION = "Unsupported wallet version '{version}'. Supported values: {supported}." UNSUPPORTED_VERSION = "Unsupported wallet version '{version}'. Supported values: {supported}."
INVALID_MNEMONIC = "Invalid mnemonic phrase: expected 12, 18, or 24 words, got {count}." INVALID_MNEMONIC = f"Invalid mnemonic phrase: expected {', '.join(str(n) for n in sorted(MNEMONIC_WORD_COUNTS_VALID))} words, got {{count}}."
INVALID_API_KEY = ( UNSUPPORTED_PROVIDER = "Unsupported API provider '{provider}'. Supported values: {supported}."
"Invalid Tonapi API key: expected at least 68 characters, got {length}. Get a key at https://tonconsole.com." INVALID_MONTHS = f"Invalid Premium duration: choose {', '.join(str(m) for m in sorted(PREMIUM_MONTHS_VALID))} months."
INVALID_STARS_AMOUNT = (
f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}."
) )
INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months." INVALID_GRAM_AMOUNT = f"Invalid GRAM (ex TON) amount: must be an integer between {GRAM_TOPUP_MIN:,} and {GRAM_TOPUP_MAX:,}."
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 = (
"Invalid username '{username}'. " "Invalid username '{username}'. "
"Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)." "Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)."
) )
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5." INVALID_WINNERS_STARS = (
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24,000." f"Invalid winners count: must be an integer between {STARS_WINNERS_MIN:,} and {STARS_WINNERS_MAX:,}."
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1,000,000." )
INVALID_WINNERS_PREMIUM = (
f"Invalid winners count: must be an integer between {PREMIUM_WINNERS_MIN:,} and {PREMIUM_WINNERS_MAX:,}."
)
INVALID_STARS_PER_WINNER = (
f"Invalid Stars per winner: must be an integer between {STARS_GIVEAWAY_MIN:,} and {STARS_GIVEAWAY_MAX:,}."
)
INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}." INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
@@ -45,7 +66,7 @@ class CookieError(ClientError):
) )
MISSING_BROWSER_KEYS = ( MISSING_BROWSER_KEYS = (
"Fragment cookies not found in {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." EXPIRED = "Fragment session cookie expired at {expires}. Log in to fragment.com in your browser and extract fresh cookies."
@@ -69,6 +90,13 @@ class UserNotFoundError(FragmentAPIError):
NOT_FOUND = ( NOT_FOUND = (
"Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists." "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): class AnonymousNumberError(FragmentAPIError):
@@ -79,7 +107,7 @@ class AnonymousNumberError(FragmentAPIError):
class TransactionError(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." INVALID_PAYLOAD = "Fragment returned an invalid transaction payload: 'transaction.messages' is missing or empty."
BROADCAST_FAILED = "Transaction broadcast failed: {exc}" BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
@@ -91,7 +119,7 @@ class TransactionError(FragmentAPIError):
"located in your Python installation folder." "located in your Python installation folder."
) )
DUPLICATE_SEQNO = ( 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) " "because a previous transaction with the same sequence number (seqno) "
"is still pending confirmation on-chain.\n" "is still pending confirmation on-chain.\n"
"Wait a few seconds for the previous transaction to confirm, then retry." "Wait a few seconds for the previous transaction to confirm, then retry."
@@ -118,14 +146,16 @@ class OperationError(FragmentError):
class WalletError(OperationError): 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." 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}" USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account 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 TON network: {exc}" WALLET_INFO_FAILED = "Failed to retrieve wallet info from GRAM (ex TON) network: {exc}"
class UnexpectedError(OperationError): class UnexpectedError(OperationError):
@@ -142,6 +172,7 @@ __all__ = [
"FragmentAPIError", "FragmentAPIError",
"FragmentPageError", "FragmentPageError",
"AnonymousNumberError", "AnonymousNumberError",
"AlreadySubscribedError",
"UserNotFoundError", "UserNotFoundError",
"TransactionError", "TransactionError",
"ParseError", "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: def __repr__(self) -> str:
return f"CookieResult(cookies={self.cookies!r}, expires={self.expires!r})" return f"CookieResult(cookies={self.cookies!r}, expires={self.expires!r})"
__all__ = ["CookieResult"]
@@ -4,9 +4,10 @@ import importlib
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any 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.exceptions import CookieError
from pyfragment.models.cookies import CookieResult from pyfragment.services.cookies.models import CookieResult
try: try:
import rookiepy import rookiepy
@@ -18,8 +19,8 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
global rookiepy global rookiepy
key = browser.lower() key = browser.lower()
if key not in SUPPORTED_BROWSERS: if not any(key == m for m in SupportedBrowser):
supported = ", ".join(sorted(SUPPORTED_BROWSERS)) supported = ", ".join(sorted(b.value for b in SupportedBrowser))
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported)) raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
try: try:
+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 typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID 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.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
from tonutils.exceptions import ProviderResponseError 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.exceptions import WalletError
from pyfragment.models.wallet import WalletInfo from pyfragment.services.tonapi.models import WalletInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
@@ -20,12 +21,19 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) 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: 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: try:
jetton_wallet_address = await get_wallet_address_get_method( jetton_wallet_address = await get_wallet_address_get_method(
client=ton, client=ton,
address=USDT_TON_MASTER_ADDRESS, address=USDT_GRAM_MASTER_ADDRESS,
owner_address=wallet_address, owner_address=wallet_address,
) )
wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_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 raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def check_ton_payment_balance( async def check_gram_payment_balance(
balance_ton: float, balance_gram: float,
amount_ton: float, amount_gram: float,
required_payment_amount: float | None, required_payment_amount: float | None,
) -> None: ) -> None:
"""Validate that the TON wallet can cover a TON-denominated payment.""" """Validate that the GRAM (ex TON) wallet can cover a GRAM (ex TON)-denominated payment."""
tx_price_ton = amount_ton tx_price_gram = amount_gram
if required_payment_amount is not None and required_payment_amount > 0: 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) required_gram = max(tx_price_gram, MIN_GRAM_BALANCE)
if balance_ton < required_ton: if balance_gram < required_gram:
logger.error( logger.error(
"Failed TON balance check: balance=%s TON, required=%s TON", "Failed GRAM (ex TON) balance check: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)",
round(balance_ton, 6), round(balance_gram, 6),
round(required_ton, 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( async def check_usdt_payment_balance(
balance_ton: float, balance_gram: float,
required_payment_amount: float | None, required_payment_amount: float | None,
ton: Any, ton: Any,
wallet_address: str, wallet_address: str,
) -> None: ) -> None:
"""Validate that the wallet can cover a USDT-denominated payment.""" """Validate that the wallet can cover a USDT-denominated payment."""
if balance_ton < MIN_TON_BALANCE: if balance_gram < MIN_GRAM_BALANCE:
logger.error( logger.error(
"Failed TON gas reserve check for USDT payment: balance=%s TON, required=%s TON", "Failed GRAM (ex TON) gas reserve check for USDT payment: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)",
round(balance_ton, 6), round(balance_gram, 6),
MIN_TON_BALANCE, 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) 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 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]: async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"""Build the wallet payload Fragment needs to prepare a transaction.""" """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: try:
wallet_cls = WALLET_CLASSES[client.wallet_version] wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) 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: async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Fetch the wallet address, chain state, and TON/USDT balances.""" """Fetch the wallet address, chain state, and GRAM (ex TON)/USDT balances."""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: async with _make_ton_client(client) as ton:
try: try:
wallet_cls = WALLET_CLASSES[client.wallet_version] wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
@@ -119,7 +127,7 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo:
return WalletInfo( return WalletInfo(
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False), address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
state=wallet.state.value, 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), usdt_balance=round(usdt_balance, 4),
) )
except Exception as exc: except Exception as exc:
@@ -7,14 +7,11 @@ from dataclasses import dataclass
class WalletInfo: class WalletInfo:
address: str address: str
state: str state: str
ton_balance: float gram_balance: float
usdt_balance: float usdt_balance: float
def __repr__(self) -> str: def __repr__(self) -> str:
return ( return (
f"WalletInfo(address='{self.address}', state='{self.state}', " 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 typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService from pyfragment.domains.base import BaseService
from pyfragment.domains.tonapi.account import get_wallet_info from pyfragment.services.tonapi.account import get_wallet_info
from pyfragment.models.wallet import WalletInfo from pyfragment.services.tonapi.models import WalletInfo
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
@@ -7,14 +7,12 @@ import random
import ssl import ssl
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from ton_core import Cell, NetworkGlobalID from ton_core import Cell
from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import WALLET_CLASSES from pyfragment.enums import WALLET_CLASSES, PaymentMethod
from pyfragment.domains.tonapi.account import check_ton_payment_balance, check_usdt_payment_balance
from pyfragment.exceptions import ParseError, TransactionError, WalletError 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: if TYPE_CHECKING:
from pyfragment.client import FragmentClient 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. """Decode a base64 BOC comment from Fragment into text when possible.
Some Fragment payloads are plain text comments, while others are structured 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. `Cell` so the caller can keep the raw binary structure.
""" """
s = payload.strip() s = payload.strip()
@@ -40,7 +38,7 @@ def clean_decode(payload: str) -> str | Cell:
sl = cell.begin_parse() sl = cell.begin_parse()
op = sl.load_uint(32) op = sl.load_uint(32)
if op != 0: if op != 0:
# Non-zero op code means this is a structured 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 return cell
try: try:
return sl.load_snake_string().strip() 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( async def _check_payment_balances(
wallet: Any, wallet: Any,
payment_method: PaymentMethod, payment_method: PaymentMethod,
amount_ton: float, amount_gram: float,
required_payment_amount: float | None, required_payment_amount: float | None,
transaction_data: dict[str, Any], transaction_data: dict[str, Any],
ton: Any, ton: Any,
@@ -71,18 +69,18 @@ async def _check_payment_balances(
"""Refresh wallet and verify sufficient balance before broadcasting.""" """Refresh wallet and verify sufficient balance before broadcasting."""
try: try:
await wallet.refresh() await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000 balance_gram = wallet.balance / 1_000_000_000
if payment_method == "ton": 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: else:
# USDT is paid from the Fragment-linked wallet, not the signing wallet. # USDT is paid from the Fragment-linked wallet, not the signing wallet.
fragment_wallet_address = transaction_data["transaction"].get("from", "") fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address) await check_usdt_payment_balance(balance_gram, required_payment_amount, ton, fragment_wallet_address)
except WalletError: except WalletError:
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to validate balances before broadcasting transaction") 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: 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: try:
result = await wallet.transfer( result = await wallet.transfer(
destination=message["address"], destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON amount=int(message["amount"]), # nanograms, not GRAM (ex TON)
body=payload, body=payload,
) )
return str(result.normalized_hash) 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( async def process_transaction(
client: FragmentClient, client: FragmentClient,
transaction_data: dict[str, Any], transaction_data: dict[str, Any],
payment_method: PaymentMethod = "ton", payment_method: PaymentMethod = PaymentMethod.GRAM,
required_payment_amount: float | None = None, required_payment_amount: float | None = None,
) -> str: ) -> 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: Args:
client: Authenticated `FragmentClient` instance. client: Authenticated `FragmentClient` instance.
@@ -140,13 +138,13 @@ async def process_transaction(
Normalized transaction hash string. Normalized transaction hash string.
""" """
message = _extract_message(transaction_data) 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_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
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", ""))) 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 raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
cause = cause.__cause__ or cause.__context__ cause = cause.__cause__ or cause.__context__
logger.exception( 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["address"],
message["amount"], message["amount"],
payment_method, payment_method,
+11 -5
View File
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project] [project]
name = "pyfragment" name = "pyfragment"
version = "2026.3.1" version = "2026.3.2"
description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings." description = "Async Python client for the Fragment API. Buy Stars and Premium, top up GRAM (ex TON) and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings."
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -91,14 +91,20 @@ ignore = ["E501", "UP017"]
[tool.mypy] [tool.mypy]
python_version = "3.10" python_version = "3.10"
strict = true strict = true
exclude = ["^examples/"] exclude = ["^examples/", "^logs/", "^tests/"]
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = "rookiepy" module = [
"rookiepy",
"httpx",
"ton_core",
"tonutils",
"tonutils.*",
]
ignore_missing_imports = true ignore_missing_imports = true
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
# rookiepy = None assignment is valid when the package is installed (type: ModuleType) # 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. # 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 warn_unused_ignores = false
+4 -4
View File
@@ -8,7 +8,7 @@ import pytest
from ton_core import Cell from ton_core import Cell
from pyfragment import ParseError from pyfragment import ParseError
from pyfragment.domains.tonapi.transaction import clean_decode from pyfragment.services.tonapi.transaction import clean_decode
PAYLOAD_CASES = [ PAYLOAD_CASES = [
pytest.param( pytest.param(
@@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None:
raw = b"\xfb\xef\xff\x00" raw = b"\xfb\xef\xff\x00"
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=") payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
with patch("pyfragment.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) result = clean_decode(payload)
mocked.assert_called_once_with(raw) mocked.assert_called_once_with(raw)
@@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None:
return _FakeSlice() return _FakeSlice()
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
with patch("pyfragment.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) parsed = clean_decode(payload)
assert parsed == "Telegram Premium Ref#abc" assert parsed == "Telegram Premium Ref#abc"
@@ -126,7 +126,7 @@ def test_clean_decode_returns_cell_for_binary_payload() -> None:
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
fake_cell: object = _FakeCell() fake_cell: object = _FakeCell()
with patch("pyfragment.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) parsed = clean_decode(payload)
assert parsed is fake_cell assert parsed is fake_cell
+32 -6
View File
@@ -5,6 +5,7 @@ import json
import pytest import pytest
from pyfragment import ConfigurationError, CookieError, FragmentClient 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 from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
# Client init tests # Client init tests
@@ -15,6 +16,30 @@ def test_valid_init() -> None:
assert client.seed == VALID_SEED.strip() assert client.seed == VALID_SEED.strip()
assert client.api_key == VALID_API_KEY assert client.api_key == VALID_API_KEY
assert client.wallet_version == "V5R1" 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 # Wallet version tests
@@ -43,6 +68,11 @@ def test_missing_seed_raises() -> None:
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES) 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: def test_whitespace_only_seed_raises() -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES) 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: 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"]) seed = " ".join(["abandon"] * (length - 1) + ["about"])
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES) client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
assert len(client.seed.split()) == length 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) 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 # Cookie validation tests
@@ -109,6 +134,7 @@ def test_repr() -> None:
r = repr(client) r = repr(client)
assert "FragmentClient" in r assert "FragmentClient" in r
assert "V5R1" in r assert "V5R1" in r
assert "tonapi" in r
assert "4 keys" 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 collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
@@ -8,7 +8,8 @@ import pytest
from tonutils.exceptions import ProviderResponseError from tonutils.exceptions import ProviderResponseError
from pyfragment import TransactionError, WalletError 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 from tests.shared import VALID_SEED
@@ -21,7 +22,7 @@ TRANSACTION_DATA = {
"messages": [ "messages": [
{ {
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a", "address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
"amount": "500000000", # 0.5 TON "amount": "500000000", # 0.5 GRAM
"payload": "", "payload": "",
} }
] ]
@@ -47,12 +48,13 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
@contextmanager @contextmanager
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]: def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
mock_ton_ctx = MagicMock()
mock_ton_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
mock_ton_ctx.__aexit__ = AsyncMock(return_value=False)
with ( with (
patch("pyfragment.domains.tonapi.transaction.TonapiClient") as mock_tonapi, patch("pyfragment.services.tonapi.transaction._make_ton_client", return_value=mock_ton_ctx),
patch("pyfragment.domains.tonapi.transaction.WALLET_CLASSES") as mock_classes, 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) mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
yield yield
@@ -62,8 +64,8 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sufficient_balance_broadcasts() -> None: async def test_sufficient_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 GRAM, above threshold
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
wallet.transfer.assert_called_once() wallet.transfer.assert_called_once()
@@ -71,7 +73,7 @@ async def test_sufficient_balance_broadcasts() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_insufficient_balance_raises() -> None: 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 _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"): with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA) await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -81,14 +83,14 @@ async def test_insufficient_balance_raises() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_exact_minimum_balance_broadcasts() -> None: async def test_exact_minimum_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
with _patch_wallet(wallet), patch("pyfragment.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) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_one_nanoton_below_minimum_raises() -> None: 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 _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"): with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA) 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: async def test_rate_limit_retries_and_succeeds() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")]) wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
with _patch_wallet(wallet), patch("pyfragment.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) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
assert wallet.transfer.call_count == 2 assert wallet.transfer.call_count == 2
@@ -134,18 +136,18 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
err = _provider_error(406, "Duplicate msg_seqno") err = _provider_error(406, "Duplicate msg_seqno")
wallet.transfer = AsyncMock(side_effect=[err, err, err]) wallet.transfer = AsyncMock(side_effect=[err, err, err])
with _patch_wallet(wallet), patch("pyfragment.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"): with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA) await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3 assert wallet.transfer.call_count == 3
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None: async def test_usdt_payment_requires_min_gram_gas_reserve() -> None:
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 GRAM below MIN_GRAM_BALANCE
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)): with _patch_wallet(wallet), patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient TON balance"): with pytest.raises(WalletError, match="Insufficient GRAM"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton") await process_transaction(_make_client(), TRANSACTION_DATA, payment_method=PaymentMethod.USDT_GRAM)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -166,13 +168,13 @@ async def test_usdt_payment_checks_usdt_balance() -> None:
with ( with (
_patch_wallet(wallet), _patch_wallet(wallet),
patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""),
patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)), patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)),
): ):
with pytest.raises(WalletError, match="Insufficient USDT balance"): with pytest.raises(WalletError, match="Insufficient USDT balance"):
await process_transaction( await process_transaction(
_make_client(), _make_client(),
transaction, transaction,
payment_method="usdt_ton", payment_method=PaymentMethod.USDT_GRAM,
required_payment_amount=12.5, 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.giveaways.giveaway as _giveaway_stars_mod
import pyfragment.domains.purchases.purchase as _purchase_stars_mod import pyfragment.domains.purchases.purchase as _purchase_stars_mod
from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError 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 from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Stars purchase validation tests # Stars purchase validation tests
@@ -15,13 +24,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSAC
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None: async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.purchase_stars("@user", amount=49) await client.purchase_stars("@user", amount=STARS_PURCHASE_MIN - 1)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None: async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): 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 @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, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_stars_mod, "process_transaction", proc_mock), 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] init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initBuyStarsRequest" assert init_call.args[0] == "initBuyStarsRequest"
@@ -113,25 +122,25 @@ async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None: async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): 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 @pytest.mark.asyncio
async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None: async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): 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 @pytest.mark.asyncio
async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None: async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): 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 @pytest.mark.asyncio
async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None: async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): 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 @pytest.mark.asyncio
@@ -165,6 +174,7 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
side_effect=[ side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}}, {"found": {"recipient": FAKE_RECIPIENT}},
{}, {},
{},
{"req_id": FAKE_REQ_ID}, {"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION, FAKE_TRANSACTION,
] ]
@@ -188,6 +198,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N
side_effect=[ side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}}, {"found": {"recipient": FAKE_RECIPIENT}},
{}, {},
{},
{"req_id": FAKE_REQ_ID}, {"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION, 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, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_giveaway_stars_mod, "process_transaction", proc_mock), 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[0] == "initGiveawayStarsRequest"
assert init_call.args[1]["payment_method"] == "usdt_ton" assert init_call.args[1]["payment_method"] == "usdt_ton"
assert proc_mock.await_args is not None 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 patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError): with pytest.raises(UserNotFoundError):
await client.giveaway_stars("@ghost", winners=1, amount=500) 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.giveaways.giveaway as _giveaway_premium_mod
import pyfragment.domains.purchases.purchase as _purchase_premium_mod import pyfragment.domains.purchases.purchase as _purchase_premium_mod
from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError 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 from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Premium purchase validation tests # Premium purchase validation tests
@@ -21,7 +24,7 @@ async def test_purchase_premium_invalid_months(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_purchase_premium_months_zero(client: FragmentClient) -> None: async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): 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 @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, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_premium_mod, "process_transaction", proc_mock), 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] init_call = call_mock.await_args_list[2]
assert init_call.args[0] == "initGiftPremiumRequest" 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" 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.asyncio
@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"]) @pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"])
async def test_purchase_premium_accepts_query_formats(client: FragmentClient, query: str) -> None: 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 @pytest.mark.asyncio
async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None: async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): 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 @pytest.mark.asyncio
async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None: async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): 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 @pytest.mark.asyncio
@@ -150,6 +172,7 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
side_effect=[ side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}}, {"found": {"recipient": FAKE_RECIPIENT}},
{}, {},
{},
{"req_id": FAKE_REQ_ID}, {"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION, FAKE_TRANSACTION,
] ]
@@ -173,6 +196,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) ->
side_effect=[ side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}}, {"found": {"recipient": FAKE_RECIPIENT}},
{}, {},
{},
{"req_id": FAKE_REQ_ID}, {"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION, 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, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_giveaway_premium_mod, "process_transaction", proc_mock), 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[0] == "initGiveawayPremiumRequest"
assert init_call.args[1]["payment_method"] == "usdt_ton" assert init_call.args[1]["payment_method"] == "usdt_ton"
assert proc_mock.await_args is not None 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 patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError): with pytest.raises(UserNotFoundError):
await client.giveaway_premium("@ghost", winners=1, months=3) 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 from unittest.mock import AsyncMock, patch
import pytest 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 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 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 @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): with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=0) await client.topup_gram("@user", amount=GRAM_TOPUP_MIN - 1)
@pytest.mark.asyncio @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): 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 @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): 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 @pytest.mark.asyncio
async def test_topup_ton_success(client: FragmentClient) -> None: async def test_topup_gram_success(client: FragmentClient) -> None:
with ( with (
patch.object( patch.object(
client, 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_gram_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, "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 isinstance(result, AdsTopupResult)
assert result.transaction_id == FAKE_TX_HASH assert result.transaction_id == FAKE_TX_HASH
@@ -59,7 +60,7 @@ async def test_topup_ton_success(client: FragmentClient) -> None:
@pytest.mark.asyncio @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( with patch.object(
client, client,
"call", "call",
@@ -71,4 +72,52 @@ async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
), ),
): ):
with pytest.raises(UserNotFoundError): 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 from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from pyfragment import FragmentClient, WalletInfo 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 @pytest.mark.asyncio
async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None: async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
mock_wallet = MagicMock() mock_wallet = MagicMock()
mock_wallet.refresh = AsyncMock() mock_wallet.refresh = AsyncMock()
mock_wallet.balance = FAKE_BALANCE_NANOTON mock_wallet.balance = FAKE_BALANCE_NANOGRAM
mock_wallet.state = MagicMock(value="active") mock_wallet.state = MagicMock(value="active")
mock_wallet.address.to_str.return_value = FAKE_ADDRESS mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with ( with (
patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi, patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes, patch("pyfragment.services.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.get_usdt_balance", AsyncMock(return_value=12.3456)),
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None) mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
result = await client.get_wallet() 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 isinstance(result, WalletInfo)
assert result.address == FAKE_ADDRESS assert result.address == FAKE_ADDRESS
assert result.state == "active" 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 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 mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with ( with (
patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi, patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes, patch("pyfragment.services.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.get_usdt_balance", AsyncMock(return_value=0.0)),
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None) mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
result = await client.get_wallet() 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.usdt_balance == 0.0
assert result.state == "uninit" 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)
+49 -2
View File
@@ -6,6 +6,7 @@ import pytest
import pyfragment.domains.ads.recharge as _recharge_ads_mod import pyfragment.domains.ads.recharge as _recharge_ads_mod
from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient 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 from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# recharge_ads validation tests # recharge_ads validation tests
@@ -14,13 +15,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANS
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None: async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): 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 @pytest.mark.asyncio
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None: async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): 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 @pytest.mark.asyncio
@@ -54,3 +55,49 @@ async def test_recharge_ads_success(client: FragmentClient) -> None:
assert isinstance(result, AdsRechargeResult) assert isinstance(result, AdsRechargeResult)
assert result.transaction_id == FAKE_TX_HASH assert result.transaction_id == FAKE_TX_HASH
assert result.amount == 10 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 return mock
PATCH = "pyfragment.core.cookies.rookiepy" PATCH = "pyfragment.services.cookies.service.rookiepy"
# unsupported browser tests # 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.ads.tonup # noqa: F401
import pyfragment.domains.giveaways.giveaway # noqa: F401 import pyfragment.domains.giveaways.giveaway # noqa: F401
import pyfragment.domains.purchases.purchase # noqa: F401 import pyfragment.domains.purchases.purchase # noqa: F401
import pyfragment.domains.tonapi.account # noqa: F401 import pyfragment.services.tonapi.account # noqa: F401
import pyfragment.domains.tonapi.transaction # noqa: F401 import pyfragment.services.tonapi.transaction # noqa: F401
from pyfragment import FragmentClient from pyfragment import FragmentClient
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
+2 -2
View File
@@ -1,7 +1,7 @@
"""Shared test constants for the pyfragment test suite. """Shared test constants for the pyfragment test suite.
pyfragment is an async Python client for the Fragment API a unified toolkit 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, run giveaways, manage anonymous numbers, and explore the marketplace for usernames,
numbers, and gifts. numbers, and gifts.
""" """
@@ -31,7 +31,7 @@ FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}}
# get_wallet() # get_wallet()
FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5" 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 # recharge_ads
FAKE_ADS_ACCOUNT: str = "@mychannel" FAKE_ADS_ACCOUNT: str = "@mychannel"