mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -37,3 +37,7 @@ systests/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
uv.lock
|
||||||
|
|
||||||
|
#docs
|
||||||
|
*-docs/
|
||||||
|
|||||||
@@ -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
@@ -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.py — ApiProvider, 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.py — exception 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
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
[](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
@@ -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.
|
||||||
|
|||||||
@@ -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):")
|
||||||
|
|||||||
@@ -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):")
|
||||||
|
|||||||
@@ -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):")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 # 1–24 000
|
WINNERS = 10 # 1–24 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 # 50–1 000 000 stars
|
AMOUNT = 500 # 50–10 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,
|
||||||
@@ -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 # 1–1 000 000 000 TON
|
AMOUNT = 10 # 1–1 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 # 1–5
|
WINNERS = 3 # 1–15
|
||||||
AMOUNT = 1000 # 500–1 000 000 stars per winner
|
AMOUNT = 1000 # 500–1 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 # 1–1 000 000 000 TON
|
AMOUNT = 10 # 1–1 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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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}')"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
-3
@@ -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
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,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,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"]
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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"]
|
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
from pyfragment.domains.tonapi.service import TonapiService
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"TonapiService",
|
|
||||||
]
|
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
PaymentMethod = Literal["ton", "usdt_ton"]
|
|
||||||
WalletVersion = Literal["V4R2", "V5R1"]
|
|
||||||
|
|
||||||
__all__ = ["PaymentMethod", "WalletVersion"]
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from pyfragment.services.cookies import CookieResult, get_cookies_from_browser
|
||||||
|
|
||||||
|
__all__ = ["CookieResult", "get_cookies_from_browser"]
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
+17
-19
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user