mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-26 06:41:42 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7225b9d16 | |||
| 27ce28fd32 | |||
| b11b9f299b | |||
| 0a361c8c3a | |||
| f46551c905 | |||
| 2bd8cc177a | |||
| fd31342d03 | |||
| d78be012a3 | |||
| ef831d12a0 | |||
| 4539dc9803 | |||
| a9663d0b08 | |||
| 228fbfcd1c | |||
| dc9661134d | |||
| 9f11ccb3ee | |||
| 8f865f4911 | |||
| 3481769757 | |||
| b85b34f2b1 | |||
| d01c45d85b | |||
| 695430744a | |||
| afaa42776b | |||
| ed54d1f1a0 | |||
| 6aa7037380 | |||
| 5124af17ef | |||
| 66bcd22198 |
@@ -12,11 +12,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7.0.1
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.11"
|
||||||
cache: pip
|
cache: pip
|
||||||
|
|
||||||
- run: pip install ".[dev]"
|
- run: pip install ".[dev]"
|
||||||
@@ -30,12 +30,12 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14"]
|
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7.0.1
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: pip
|
cache: pip
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
is-new: ${{ steps.tag.outputs.is-new }}
|
is-new: ${{ steps.tag.outputs.is-new }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -42,17 +42,17 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7.0.1
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v8.1.0
|
- uses: astral-sh/setup-uv@v9.0.0
|
||||||
|
|
||||||
- run: uv build
|
- run: uv build
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v7
|
- uses: actions/upload-artifact@v7.0.1
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist/*
|
path: dist/*
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v8.0.1
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist
|
path: dist
|
||||||
@@ -83,15 +83,25 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/checkout@v7.0.1
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v8.0.1
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist
|
path: dist
|
||||||
|
|
||||||
- uses: softprops/action-gh-release@v3
|
- name: Extract latest changelog entry
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||||
|
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
echo "$body" >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- uses: softprops/action-gh-release@v3.0.2
|
||||||
with:
|
with:
|
||||||
tag_name: v${{ needs.version-check.outputs.version }}
|
tag_name: v${{ needs.version-check.outputs.version }}
|
||||||
name: v${{ needs.version-check.outputs.version }}
|
name: v${{ needs.version-check.outputs.version }}
|
||||||
files: dist/*
|
files: dist/*
|
||||||
generate_release_notes: true
|
body: ${{ steps.changelog.outputs.body }}
|
||||||
make_latest: true
|
make_latest: true
|
||||||
|
|||||||
+28
-24
@@ -1,39 +1,43 @@
|
|||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.py[cod]
|
||||||
*.pyo
|
*$py.class
|
||||||
*.pyd
|
*.so
|
||||||
.Python
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual Environment
|
||||||
.venv/
|
|
||||||
venv/
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Environment variables
|
#docs
|
||||||
.env
|
*-docs/
|
||||||
|
|
||||||
# System files
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
uv.lock
|
||||||
# Testing & tooling artifacts
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
.mypy_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
.coverage
|
|
||||||
htmlcov/
|
|
||||||
systests/
|
|
||||||
|
|
||||||
# Build & distribution
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
*.egg-info/
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+10
-8
@@ -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)
|
||||||
```
|
```
|
||||||
@@ -45,8 +47,8 @@ examples/ — runnable usage examples (excluded from CI)
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- 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 HTTP 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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="pyfragment" width="96" height="96" style="border-radius: 20px;"><br><br>
|
<img src="icon.svg" alt="pyfragment" width="96" height="96" style="border-radius: 20px;"><br><br>
|
||||||
|
|
||||||
# pyfragment
|
# pyfragment
|
||||||
|
|
||||||
@@ -9,18 +9,14 @@
|
|||||||
[](https://github.com/bohd4nx/pyfragment/actions)
|
[](https://github.com/bohd4nx/pyfragment/actions)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
Async Python client for the **[Fragment](https://fragment.com)** marketplace API.
|
Async Python client for the **[Fragment.com](https://fragment.com)** marketplace API.
|
||||||
Buy Stars & Premium, run giveaways, top up TON and Ads balances,
|
|
||||||
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)**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
|
> **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -38,123 +34,32 @@ 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",
|
||||||
cookies={
|
cookies={"stel_ssid": "...", "stel_dt": "...", "stel_token": "...", "stel_ton_token": "..."},
|
||||||
"stel_ssid": "...",
|
|
||||||
"stel_dt": "...",
|
|
||||||
"stel_token": "...",
|
|
||||||
"stel_ton_token": "...",
|
|
||||||
},
|
|
||||||
) 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("GRAM: %s | USDT: %s" % (wallet.gram_balance, wallet.usdt_balance))
|
||||||
|
|
||||||
recipient = "https://t.me/username" # also: @username, username
|
stars = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.USDT_GRAM)
|
||||||
|
print("Sent %s Stars to %s | tx: %s" % (stars.amount, stars.username, stars.transaction_id))
|
||||||
|
|
||||||
stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton")
|
premium = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM)
|
||||||
print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}")
|
print("Sent Premium %sm to %s | tx: %s" % (premium.amount, premium.username, premium.transaction_id))
|
||||||
|
|
||||||
premium = await client.purchase_premium(recipient, months=6, payment_method="ton")
|
|
||||||
print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}")
|
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
|
||||||
| ---------------- | ------------- | -------- | ----------------------------------------------------------- |
|
|
||||||
| `seed` | `str` | — | 12- or 24-word TON wallet mnemonic |
|
|
||||||
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
|
|
||||||
| `cookies` | `dict \| str` | — | Fragment session cookies |
|
|
||||||
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal |
|
|
||||||
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Credentials
|
|
||||||
|
|
||||||
### Fragment cookies
|
|
||||||
|
|
||||||
Log in to [fragment.com](https://fragment.com) and connect your TON wallet.
|
|
||||||
|
|
||||||
**Automatically** (recommended) — reads directly from your browser, no extension needed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install "pyfragment[browser]"
|
|
||||||
```
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyfragment import get_cookies_from_browser
|
|
||||||
|
|
||||||
result = get_cookies_from_browser("chrome") # firefox, edge, brave, ...
|
|
||||||
# result.cookies — dict[str, str] ready to pass to FragmentClient
|
|
||||||
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manually** — use [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass as a `dict` or JSON string.
|
|
||||||
|
|
||||||
Refresh cookies when you get authentication errors.
|
|
||||||
|
|
||||||
### Tonapi key
|
|
||||||
|
|
||||||
Generate at [tonconsole.com](https://tonconsole.com).
|
|
||||||
|
|
||||||
### Seed phrase
|
|
||||||
|
|
||||||
12- or 24-word mnemonic from your TON wallet (**Tonkeeper → Settings → Backup**). Never share it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
|
|
||||||
`pyfragment` uses the standard `logging` module under the `pyfragment` namespace and is silent by default:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
logging.getLogger("pyfragment").setLevel(logging.DEBUG) # for detailed request logs
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
All exceptions inherit from `FragmentError`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyfragment import (
|
|
||||||
ConfigurationError, # invalid arguments (amount, months, payment_method…)
|
|
||||||
UserNotFoundError, # recipient not found on Fragment
|
|
||||||
WalletError, # insufficient TON or USDT balance
|
|
||||||
TransactionError, # broadcast failed, duplicate seqno, invalid payload
|
|
||||||
FragmentAPIError, # Fragment API returned an error response
|
|
||||||
FragmentPageError, # page fetch or hash extraction failed
|
|
||||||
AnonymousNumberError, # number not owned, wrong state, login code issues
|
|
||||||
CookieError, # missing or malformed session cookies
|
|
||||||
ParseError, # failed to decode Fragment payload
|
|
||||||
VerificationError, # on-chain verification step failed
|
|
||||||
OperationError, # generic operation-level failure
|
|
||||||
UnexpectedError, # unexpected API response structure
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
|
[Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
|
||||||
|
|
||||||
**Star ⭐ if you found it useful**
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+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__":
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512" height="512" style="width: 100%; height: 100%; transform: translate3d(0,0,0); content-visibility: visible;" preserveAspectRatio="xMidYMid meet"><defs><clipPath id="__lottie_element_2"><rect width="512" height="512" x="0" y="0"></rect></clipPath><clipPath id="__lottie_element_4"><path d="M0,0 L100,0 L100,100 L0,100z"></path></clipPath></defs><g clip-path="url(#__lottie_element_2)"><g clip-path="url(#__lottie_element_4)" style="display: block;" transform="matrix(5.119999885559082,0,0,5.119999885559082,0,0)" opacity="1"><g style="display: block;" transform="matrix(1.333299994468689,0,0,1.333299994468689,0,0)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(30,40,51)" fill-opacity="1" d=" M47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 27.68000030517578,5.619999885559082 27.68000030517578,5.619999885559082 C15.5,5.619999885559082 5.619999885559082,15.5 5.619999885559082,27.68000030517578 C5.619999885559082,27.68000030517578 5.619999885559082,47.31999969482422 5.619999885559082,47.31999969482422 C5.619999885559082,59.5 15.5,69.37999725341797 27.68000030517578,69.37999725341797 C27.68000030517578,69.37999725341797 47.31999969482422,69.37999725341797 47.31999969482422,69.37999725341797 C59.5,69.37999725341797 69.37999725341797,59.5 69.37999725341797,47.31999969482422 C69.37999725341797,47.31999969482422 69.37999725341797,27.68000030517578 69.37999725341797,27.68000030517578 C69.37999725341797,15.5 59.5,5.619999885559082 47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082z"></path></g><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(255,255,255)" fill-opacity="1" d=" M36.349998474121094,32.79999923706055 C36.349998474121094,32.79999923706055 21.1299991607666,25.940000534057617 21.1299991607666,25.940000534057617 C20.020000457763672,25.450000762939453 20.389999389648438,23.790000915527344 21.600000381469727,23.790000915527344 C21.600000381469727,23.790000915527344 53.41999816894531,23.790000915527344 53.41999816894531,23.790000915527344 C54.630001068115234,23.790000915527344 54.9900016784668,25.440000534057617 53.880001068115234,25.940000534057617 C53.880001068115234,25.940000534057617 38.66999816894531,32.79999923706055 38.66999816894531,32.79999923706055 C37.939998626708984,33.130001068115234 37.09000015258789,33.130001068115234 36.36000061035156,32.79999923706055 C36.36000061035156,32.79999923706055 36.349998474121094,32.79999923706055 36.349998474121094,32.79999923706055z M56.81999969482422,30.06999969482422 C57.43000030517578,29.1200008392334 56.43000030517578,27.979999542236328 55.400001525878906,28.440000534057617 C55.400001525878906,28.440000534057617 40.72999954223633,35.13999938964844 40.72999954223633,35.13999938964844 C39.72999954223633,35.599998474121094 39.09000015258789,36.61000061035156 39.09000015258789,37.70000076293945 C39.09000015258789,37.70000076293945 39.09000015258789,53.810001373291016 39.09000015258789,53.810001373291016 C39.09000015258789,54.93000030517578 40.54999923706055,55.36000061035156 41.15999984741211,54.41999816894531 C41.15999984741211,54.41999816894531 56.810001373291016,30.06999969482422 56.810001373291016,30.06999969482422 C56.810001373291016,30.06999969482422 56.81999969482422,30.06999969482422 56.81999969482422,30.06999969482422z M19.600000381469727,28.440000534057617 C18.579999923706055,27.979999542236328 17.56999969482422,29.1200008392334 18.18000030517578,30.06999969482422 C18.18000030517578,30.06999969482422 33.84000015258789,54.43000030517578 33.84000015258789,54.43000030517578 C34.45000076293945,55.380001068115234 35.90999984741211,54.939998626708984 35.90999984741211,53.81999969482422 C35.90999984741211,53.81999969482422 35.90999984741211,37.70000076293945 35.90999984741211,37.70000076293945 C35.90999984741211,36.599998474121094 35.27000045776367,35.599998474121094 34.27000045776367,35.13999938964844 C34.27000045776367,35.13999938964844 19.59000015258789,28.450000762939453 19.59000015258789,28.450000762939453 C19.59000015258789,28.450000762939453 19.600000381469727,28.440000534057617 19.600000381469727,28.440000534057617z"></path></g></g></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
+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",
|
||||||
@@ -63,15 +24,60 @@ BASE_HEADERS: dict[str, str] = {
|
|||||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
"origin": FRAGMENT_BASE_URL,
|
"origin": FRAGMENT_BASE_URL,
|
||||||
"priority": "u=1, i",
|
"priority": "u=1, i",
|
||||||
"sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
|
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
|
||||||
"sec-ch-ua-mobile": "?1",
|
"sec-ch-ua-mobile": "?1",
|
||||||
"sec-ch-ua-platform": '"Android"',
|
"sec-ch-ua-platform": '"Android"',
|
||||||
"sec-fetch-dest": "empty",
|
"sec-fetch-dest": "empty",
|
||||||
"sec-fetch-mode": "cors",
|
"sec-fetch-mode": "cors",
|
||||||
"sec-fetch-site": "same-origin",
|
"sec-fetch-site": "same-origin",
|
||||||
"user-agent": (
|
"user-agent": (
|
||||||
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
|
"Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36"
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
|
|
||||||
),
|
),
|
||||||
"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})
|
||||||
|
|||||||
@@ -5,55 +5,47 @@ import random
|
|||||||
import re
|
import re
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
import httpx
|
from curl_cffi.requests import AsyncSession, Response
|
||||||
|
|
||||||
from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
|
from pyfragment.core.constants import FRAGMENT_BASE_URL
|
||||||
from pyfragment.exceptions import FragmentPageError, ParseError, VerificationError
|
from pyfragment.exceptions import FragmentPageError, ParseError
|
||||||
|
|
||||||
|
|
||||||
async def get_fragment_hash(
|
async def get_fragment_hash(
|
||||||
cookies: dict[str, Any],
|
session: AsyncSession[Any],
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
page_url: str,
|
page_url: str,
|
||||||
timeout: float = DEFAULT_TIMEOUT,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
page_headers = {
|
# Derive the natural referer: strip the last path segment (e.g. /stars/buy → /stars)
|
||||||
k: v
|
parent_url = page_url.rsplit("/", 1)[0] or FRAGMENT_BASE_URL
|
||||||
for k, v in headers.items()
|
|
||||||
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
|
|
||||||
}
|
|
||||||
page_headers.update(
|
|
||||||
{
|
|
||||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
||||||
"referer": f"{FRAGMENT_BASE_URL}/",
|
|
||||||
"sec-fetch-dest": "document",
|
|
||||||
"sec-fetch-mode": "navigate",
|
|
||||||
"upgrade-insecure-requests": "1",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
|
page_headers = {k: v for k, v in headers.items() if k not in ("content-type", "origin")}
|
||||||
response = await session.get(page_url, headers=page_headers)
|
page_headers["referer"] = parent_url
|
||||||
|
page_headers["x-aj-referer"] = parent_url
|
||||||
|
page_headers.pop("x-aj-referer", None)
|
||||||
|
page_headers.pop("x-requested-with", None)
|
||||||
|
|
||||||
|
response = await session.get(page_url, headers=page_headers)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
|
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
|
||||||
|
|
||||||
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
|
match = re.search(r"(?:https://fragment\.com)?\\\\?/api\?hash=([a-f0-9]+)", response.text)
|
||||||
if not match:
|
if not match:
|
||||||
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
|
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
|
||||||
|
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
|
def parse_json_response(response: Response, context: str) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return cast(dict[str, Any], response.json())
|
return cast(dict[str, Any], response.json()) # type: ignore[no-untyped-call]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
async def fragment_request(
|
async def fragment_request(
|
||||||
session: httpx.AsyncClient,
|
session: AsyncSession[Any],
|
||||||
fragment_hash: str,
|
fragment_hash: str,
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
@@ -73,17 +65,3 @@ async def fragment_request(
|
|||||||
)
|
)
|
||||||
return parse_json_response(resp, data.get("method", "request"))
|
return parse_json_response(resp, data.get("method", "request"))
|
||||||
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
|
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
|
||||||
|
|
||||||
|
|
||||||
async def execute_transaction_request(
|
|
||||||
session: httpx.AsyncClient,
|
|
||||||
headers: dict[str, str],
|
|
||||||
tx_data: dict[str, Any],
|
|
||||||
fragment_hash: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
|
|
||||||
|
|
||||||
if transaction.get("need_verify"):
|
|
||||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
|
||||||
|
|
||||||
return transaction
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import httpx
|
from curl_cffi.requests import AsyncSession
|
||||||
|
|
||||||
from pyfragment.core.constants import BASE_HEADERS
|
from pyfragment.core.constants import BASE_HEADERS
|
||||||
from pyfragment.core.transport import fragment_request, get_fragment_hash
|
from pyfragment.core.transport import fragment_request, get_fragment_hash
|
||||||
@@ -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 AsyncSession(cookies=cookies, timeout=timeout, impersonate="chrome") as session:
|
||||||
fragment_hash = await get_fragment_hash(cookies, headers, page_url, timeout)
|
fragment_hash = await get_fragment_hash(session, call_headers, page_url)
|
||||||
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,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from tonutils.contracts.wallet import WalletHighloadV2, WalletHighloadV3R1, WalletV4R2, WalletV5R1
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentMethod(StrEnum):
|
||||||
|
GRAM = "ton"
|
||||||
|
USDT_GRAM = "usdt_ton"
|
||||||
|
|
||||||
|
# Not supported yet
|
||||||
|
USDT_ETH = "usdt_eth"
|
||||||
|
USDT_POL = "usdt_pol"
|
||||||
|
USDC_ETH = "usdc_eth"
|
||||||
|
USDC_BASE = "usdc_base"
|
||||||
|
USDC_POL = "usdc_pol"
|
||||||
|
|
||||||
|
|
||||||
|
class WalletVersion(StrEnum):
|
||||||
|
V4R2 = "V4R2"
|
||||||
|
V5R1 = "V5R1"
|
||||||
|
HighloadV2 = "HighloadV2"
|
||||||
|
HighloadV3R1 = "HighloadV3R1"
|
||||||
|
|
||||||
|
|
||||||
|
WALLET_CLASSES: dict[WalletVersion, Any] = {
|
||||||
|
WalletVersion.V4R2: WalletV4R2,
|
||||||
|
WalletVersion.V5R1: WalletV5R1,
|
||||||
|
WalletVersion.HighloadV2: WalletHighloadV2,
|
||||||
|
WalletVersion.HighloadV3R1: WalletHighloadV3R1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ApiProvider(StrEnum):
|
||||||
|
TONAPI = "tonapi" # tonconsole.com — default
|
||||||
|
TONCENTER = "toncenter" # t.me/toncenter
|
||||||
|
|
||||||
|
|
||||||
|
class SupportedBrowser(StrEnum):
|
||||||
|
ARC = "arc"
|
||||||
|
BRAVE = "brave"
|
||||||
|
CHROME = "chrome"
|
||||||
|
CHROMIUM = "chromium"
|
||||||
|
CHROMIUM_BASED = "chromium_based"
|
||||||
|
EDGE = "edge"
|
||||||
|
FIREFOX = "firefox"
|
||||||
|
FIREFOX_BASED = "firefox_based"
|
||||||
|
LIBREWOLF = "librewolf"
|
||||||
|
OPERA = "opera"
|
||||||
|
OPERA_GX = "opera_gx"
|
||||||
|
SAFARI = "safari"
|
||||||
|
VIVALDI = "vivaldi"
|
||||||
+47
-20
@@ -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,22 @@ 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_WINNERS_STARS = (
|
||||||
INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1,000,000,000."
|
f"Invalid winners count: must be an integer between {STARS_WINNERS_MIN:,} and {STARS_WINNERS_MAX:,}."
|
||||||
INVALID_USERNAME = (
|
)
|
||||||
"Invalid username '{username}'. "
|
INVALID_WINNERS_PREMIUM = (
|
||||||
"Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)."
|
f"Invalid winners count: must be an integer between {PREMIUM_WINNERS_MIN:,} and {PREMIUM_WINNERS_MAX:,}."
|
||||||
|
)
|
||||||
|
INVALID_STARS_PER_WINNER = (
|
||||||
|
f"Invalid Stars per winner: must be an integer between {STARS_GIVEAWAY_MIN:,} and {STARS_GIVEAWAY_MAX:,}."
|
||||||
)
|
)
|
||||||
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
|
|
||||||
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24,000."
|
|
||||||
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1,000,000."
|
|
||||||
INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
|
INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +62,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 +86,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 +103,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 +115,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 +142,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 +168,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"]
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
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:
|
||||||
@@ -41,11 +42,11 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
|
|||||||
if cookie.get("name") == "stel_ssid":
|
if cookie.get("name") == "stel_ssid":
|
||||||
raw = cookie.get("expires")
|
raw = cookie.get("expires")
|
||||||
if isinstance(raw, (int, float)):
|
if isinstance(raw, (int, float)):
|
||||||
expires_iso = datetime.fromtimestamp(raw, tz=timezone.utc).isoformat()
|
expires_iso = datetime.fromtimestamp(raw, tz=UTC).isoformat()
|
||||||
elif isinstance(raw, str) and raw:
|
elif isinstance(raw, str) and raw:
|
||||||
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
|
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
|
||||||
try:
|
try:
|
||||||
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc).isoformat()
|
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=UTC).isoformat()
|
||||||
break
|
break
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
@@ -53,7 +54,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
|
|||||||
|
|
||||||
if expires_iso:
|
if expires_iso:
|
||||||
expires_dt = datetime.fromisoformat(expires_iso)
|
expires_dt = datetime.fromisoformat(expires_iso)
|
||||||
if expires_dt < datetime.now(timezone.utc):
|
if expires_dt < datetime.now(UTC):
|
||||||
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
|
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
|
||||||
|
|
||||||
return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso)
|
return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso)
|
||||||
@@ -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,
|
||||||
+15
-32
@@ -4,62 +4,43 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pyfragment"
|
name = "pyfragment"
|
||||||
version = "2026.3.1"
|
version = "2026.3.3"
|
||||||
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.com marketplace API."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.11"
|
||||||
authors = [{ name = "bohd4nx" }]
|
authors = [{ name = "bohd4nx" }]
|
||||||
keywords = [
|
keywords = [
|
||||||
"fragment",
|
"fragment",
|
||||||
"fragment-api",
|
|
||||||
"telegram",
|
"telegram",
|
||||||
"telegram-api",
|
|
||||||
"telegram-stars",
|
"telegram-stars",
|
||||||
"telegram-premium",
|
"telegram-premium",
|
||||||
"telegram-giveaway",
|
|
||||||
"telegram-ads",
|
|
||||||
"ton",
|
"ton",
|
||||||
"ton-blockchain",
|
|
||||||
"tonapi",
|
"tonapi",
|
||||||
"anonymous-numbers",
|
"anonymous-numbers",
|
||||||
"username-auctions",
|
|
||||||
"gift-marketplace",
|
|
||||||
"crypto-payments",
|
|
||||||
"nft-marketplace",
|
|
||||||
"web3",
|
|
||||||
"python-client",
|
|
||||||
"typed",
|
|
||||||
"asyncio",
|
"asyncio",
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Natural Language :: English",
|
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python",
|
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
"Programming Language :: Python :: 3.14",
|
"Programming Language :: Python :: 3.14",
|
||||||
"Framework :: AsyncIO",
|
"Framework :: AsyncIO",
|
||||||
"Topic :: Software Development :: Libraries",
|
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
"Topic :: Internet",
|
|
||||||
"Topic :: Internet :: WWW/HTTP",
|
|
||||||
"Typing :: Typed",
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
dependencies = ["httpx>=0.25", "tonutils>=2.0.1"]
|
dependencies = ["curl-cffi>=0.14.0", "tonutils>=2.0.1"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
|
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
|
||||||
browser = ["rookiepy>=0.5.6"]
|
browser = ["rookiepy>=0.5.6"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/bohd4nx/pyfragment"
|
|
||||||
Repository = "https://github.com/bohd4nx/pyfragment"
|
Repository = "https://github.com/bohd4nx/pyfragment"
|
||||||
Documentation = "https://bohd4nx.gitbook.io/pyfragment/"
|
Documentation = "https://bohd4nx.gitbook.io/pyfragment/"
|
||||||
Issues = "https://github.com/bohd4nx/pyfragment/issues"
|
Issues = "https://github.com/bohd4nx/pyfragment/issues"
|
||||||
@@ -76,29 +57,31 @@ addopts = "-v --tb=short"
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 128
|
line-length = 128
|
||||||
target-version = "py310"
|
target-version = "py311"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
|
|
||||||
select = ["E", "F", "W", "I", "UP"]
|
select = ["E", "F", "W", "I", "UP"]
|
||||||
# E501 — line too long (covered by line-length above)
|
ignore = ["E501"]
|
||||||
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
|
|
||||||
ignore = ["E501", "UP017"]
|
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"tests/*" = ["E402"]
|
"tests/*" = ["E402"]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.10"
|
python_version = "3.11"
|
||||||
strict = true
|
strict = true
|
||||||
exclude = ["^examples/"]
|
exclude = ["^examples/", "^logs/", "^tests/"]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = "rookiepy"
|
module = [
|
||||||
|
"rookiepy",
|
||||||
|
"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)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from curl_cffi.requests import AsyncSession, Response
|
||||||
|
|
||||||
from pyfragment import FragmentClient, FragmentPageError
|
from pyfragment import FragmentClient, FragmentPageError
|
||||||
from pyfragment.core.transport import fragment_request
|
from pyfragment.core.transport import fragment_request
|
||||||
@@ -67,10 +67,10 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fragment_request_non_200_raises() -> None:
|
async def test_fragment_request_non_200_raises() -> None:
|
||||||
response = MagicMock(spec=httpx.Response)
|
response = MagicMock(spec=Response)
|
||||||
response.status_code = 429
|
response.status_code = 429
|
||||||
|
|
||||||
session = AsyncMock(spec=httpx.AsyncClient)
|
session = AsyncMock(spec=AsyncSession)
|
||||||
session.post = AsyncMock(return_value=response)
|
session.post = AsyncMock(return_value=response)
|
||||||
|
|
||||||
with pytest.raises(FragmentPageError, match="429"):
|
with pytest.raises(FragmentPageError, match="429"):
|
||||||
|
|||||||
@@ -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
-6
@@ -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,15 +31,11 @@ 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"
|
||||||
|
|
||||||
# Revenue withdrawals
|
|
||||||
FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
||||||
FAKE_REVENUE_TX: str = "revenue_tx_abc123"
|
|
||||||
|
|
||||||
# Anonymous number
|
# Anonymous number
|
||||||
FAKE_HTML_WITH_CODE: str = """
|
FAKE_HTML_WITH_CODE: str = """
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
Reference in New Issue
Block a user