6 Commits

Author SHA1 Message Date
Bohdan 👾 5f8197b710 Merge pull request #25 from bohd4nx/refactor/domain-oriented-sdk
Refactor/domain oriented sdk
2026-05-21 17:19:25 +03:00
Bohdan 👾 e1478ec8b0 Merge pull request #24 from bohd4nx/dev
Bump version to 2026.2.3 and refactor wallet utilities
2026-05-12 02:03:12 +03:00
Bohdan 👾 35ab0e337d Merge pull request #22 from bohd4nx/dev
Release 2026.2.2: payment_method cleanup, wallet balance behavior updates, docs/tests sync
2026-05-11 13:26:04 +03:00
Bohdan 👾 eec59f7e12 Merge pull request #20 from bohd4nx/dev
Dev
2026-05-03 19:21:35 +03:00
Bohdan 👾 1f6085acdc Merge pull request #18 from bohd4nx/dev
Dev
2026-05-03 19:16:19 +03:00
Bohdan 👾 1a070ce8b9 Merge pull request #13 from bohd4nx/dev
feat: browser cookie extraction, CookieResult type, Python 3.10+ support
2026-04-14 02:00:46 +03:00
78 changed files with 1047 additions and 1910 deletions
+11 -9
View File
@@ -10,35 +10,37 @@ jobs:
lint: lint:
name: Lint & Format name: Lint & Format
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v6
- uses: actions/setup-python@v7.0.0 - uses: actions/setup-python@v6
with: with:
python-version: "3.11" python-version: "3.10"
cache: pip
- run: pip install ".[dev]" - run: pip install ".[dev]"
- run: ruff check . && ruff format --check . && mypy pyfragment --explicit-package-bases - run: ruff check . && ruff format --check . && mypy pyfragment
test: test:
name: Tests (Python ${{ matrix.python-version }}) name: Tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"] python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v6
- uses: actions/setup-python@v7.0.0 - uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: pip
- name: Install package and dev dependencies - name: Install package and dev dependencies
run: pip install ".[dev]" run: pip install ".[dev]"
+19 -9
View File
@@ -11,12 +11,14 @@ jobs:
name: Version Check name: Version Check
if: github.event.workflow_run.conclusion == 'success' if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
outputs: outputs:
version: ${{ steps.version.outputs.value }} version: ${{ steps.version.outputs.value }}
is-new: ${{ steps.tag.outputs.is-new }} is-new: ${{ steps.tag.outputs.is-new }}
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -40,19 +42,21 @@ jobs:
needs: version-check needs: version-check
if: needs.version-check.outputs.is-new == 'true' if: needs.version-check.outputs.is-new == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v6
- uses: actions/setup-python@v7.0.0 - uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- uses: astral-sh/setup-uv@v9.0.0 - uses: astral-sh/setup-uv@v8.1.0
- run: uv build - run: uv build
- uses: actions/upload-artifact@v7.0.1 - uses: actions/upload-artifact@v7
with: with:
name: dist name: dist
path: dist/* path: dist/*
@@ -61,6 +65,8 @@ jobs:
name: Publish to PyPI name: Publish to PyPI
needs: [ version-check, build ] needs: [ version-check, build ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
environment: environment:
name: pypi name: pypi
url: https://pypi.org/project/pyfragment/ url: https://pypi.org/project/pyfragment/
@@ -68,7 +74,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- uses: actions/download-artifact@v8.0.1 - uses: actions/download-artifact@v8
with: with:
name: dist name: dist
path: dist path: dist
@@ -79,13 +85,17 @@ jobs:
name: GitHub Release name: GitHub Release
needs: [ version-check, build ] needs: [ version-check, build ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/download-artifact@v8.0.1 - uses: actions/download-artifact@v8
with: with:
name: dist name: dist
path: dist path: dist
@@ -98,7 +108,7 @@ jobs:
echo "$body" >> $GITHUB_OUTPUT echo "$body" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
- uses: softprops/action-gh-release@v3.0.2 - uses: softprops/action-gh-release@v3
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 }}
+24 -28
View File
@@ -1,43 +1,39 @@
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.pyc
*$py.class *.pyo
*.so *.pyd
.Python .Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment # Virtual environments
venv/
.venv/ .venv/
venv/
# IDE # IDE
.vscode/
.idea/ .idea/
*.swp .vscode/
*.swo
*~
# Logs # Logs
logs/
*.log *.log
#docs # Environment variables
*-docs/ .env
# OS # System files
.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/
-81
View File
@@ -7,85 +7,6 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
--- ---
## [2026.3.2] — 2026-06-16
### Added
- Added `ApiProvider` enum with `TONAPI` (tonconsole.com, default) and `TONCENTER` (t.me/toncenter) values.
- Added `api_provider` parameter to `FragmentClient` — select the blockchain API provider at init time (`"tonapi"` or `"toncenter"`).
- Both providers accept `api_key` with the same interface; the correct `tonutils` client is selected automatically.
- New `AlreadySubscribedError` exception for Premium purchase flows when Fragment returns: `This account is already subscribed to Telegram Premium.`
- New `UserNotFoundError.NOT_A_USER` message for when Fragment returns: `Please enter a username assigned to a user.` (e.g. when the username belongs to a channel or bot).
- Added `WalletVersion.HighloadV2` and `WalletVersion.HighloadV3R1` to `WalletVersion`
### Changed
- Updated purchase and giveaway flow state nonces (`dh`) to use nonce-like dynamic values with a wider integer range.
- Stars and Premium giveaway flows now include explicit price update steps before init requests:
- `updateStarsGiveawayPrices`
- `updatePremiumGiveawayPrices`
- Updated `DEVICE_INFO` fingerprint: Tonkeeper `appVersion` -> `26.05.0`.
- Updated client docstrings and purchase examples to document all supported payment methods.
### Renamed — TON -> GRAM (ex TON)
The TON blockchain has been rebranded to **GRAM (ex TON)**. All identifiers, messages, and documentation have been updated accordingly.
**Public API**
- `FragmentClient.topup_ton()``topup_gram()`
- `PaymentMethod.TON``PaymentMethod.GRAM`
- `PaymentMethod.USDT_TON``PaymentMethod.USDT_GRAM`
- `WalletInfo.ton_balance``WalletInfo.gram_balance`
**Constants**
- `TON_TOPUP_MIN` / `TON_TOPUP_MAX``GRAM_TOPUP_MIN` / `GRAM_TOPUP_MAX`
- `MIN_TON_BALANCE``MIN_GRAM_BALANCE`
- `USDT_TON_MASTER_ADDRESS``USDT_GRAM_MASTER_ADDRESS`
**Exceptions**
- `ConfigurationError.INVALID_TON_AMOUNT``INVALID_GRAM_AMOUNT`
- `WalletError.LOW_TON_BALANCE``LOW_GRAM_BALANCE`
- `WalletError.TON_BALANCE_CHECK_FAILED``GRAM_BALANCE_CHECK_FAILED`
**Internals**
- `pyfragment/core/constants/ton.py``gram.py`
- `check_ton_payment_balance()``check_gram_payment_balance()`
---
## [2026.3.1] — 2026-05-29
### Added
- Python 3.13 and 3.14 are now officially supported and included in the CI test matrix and PyPI classifiers.
- `WalletVersion` is now exported from the top-level `pyfragment` package.
### Changed
- `process_transaction` (internal) refactored into focused subfunctions: `_extract_message`, `_check_payment_balances`, `_broadcast_with_retry`.
- `raw_api_call()` moved from `FragmentClient` into `pyfragment.domains.base` and exposed as a standalone helper.
- `tonapi` domain internal helpers removed from public `__init__.py` exports; only `TonapiService` is exported.
- README rewritten with badges, structured sections, and complete usage examples.
- Added `CONTRIBUTING.md` and `SECURITY.md`.
### Fixed
- CI: `mypy` now runs with `--explicit-package-bases` to avoid false-positive import errors.
- CI: `pip` dependency cache enabled to speed up workflow runs.
- CI: `warn_unused_ignores` suppressed for `pyfragment.core.cookies` to handle the optional `rookiepy` dependency correctly across environments where the package may or may not be installed.
- Publish workflow now uses `generate_release_notes: true` instead of manual changelog extraction.
### Removed
- `tonapi/transfer.py` and associated `TonTransferResult` / `UsdtTransferResult` models (internal, unused).
---
## [2026.3.0] — 2026-05-21 ## [2026.3.0] — 2026-05-21
### Changed ### Changed
@@ -271,8 +192,6 @@ The TON blockchain has been rebranded to **GRAM (ex TON)**. All identifiers, mes
- `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
-58
View File
@@ -1,58 +0,0 @@
# Contributing to pyfragment
## Development setup
```bash
git clone https://github.com/bohd4nx/pyfragment.git
cd pyfragment
pip install -e ".[dev]"
```
## Running checks
```bash
# Lint and format
ruff check . --fix && ruff format .
# Type check
mypy . --explicit-package-bases
# Tests
pytest
```
All three must pass before opening a PR.
## Project structure
```
pyfragment/
client.py — FragmentClient (public entry point)
enums.py — ApiProvider, PaymentMethod, WalletVersion
exceptions.py — exception hierarchy
core/ — constants, validation helpers
domains/ — one package per feature domain
ads/ — recharge_ads, topup_gram
anonymous_numbers/— get_login_code, toggle_login_codes, terminate_sessions
giveaways/ — giveaway_stars, giveaway_premium
marketplace/ — search_usernames, search_numbers, search_gifts
purchases/ — purchase_stars, purchase_premium
services/ — shared infrastructure services
cookies/ — browser cookie extraction (models + service)
tonapi/ — wallet info, transaction signing (tonapi/toncenter)
tests/ — unit tests (pytest)
examples/ — runnable usage examples (excluded from CI)
```
## Conventions
- All public async methods live on `FragmentClient` and delegate to a domain service.
- Domain functions receive a `FragmentClient` instance, never raw HTTP clients.
- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.services.tonapi.transaction._make_ton_client`.
- Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`.
## Pull requests
- Keep PRs focused — one feature or fix per PR.
- Update `CHANGELOG.md` under `[Unreleased]`.
- Add or update tests for any changed behaviour.
+86 -22
View File
@@ -1,21 +1,25 @@
<div align="center"> <div align="center">
<img src="icon.svg" alt="pyfragment" width="96" height="96" style="border-radius: 20px;"><br><br> <img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="Fragment Logo" width="120" height="120" style="border-radius: 24px;">
# pyfragment <h1 style="margin-top: 24px;">Fragment API</h1>
[![PyPI](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue&label=PyPI)](https://pypi.org/project/pyfragment/) <p style="font-size: 18px; margin-bottom: 24px;">
[![Downloads](https://img.shields.io/pepy/dt/pyfragment?style=flat&color=blue&label=Downloads)](https://pepy.tech/projects/pyfragment) <b>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.</b>
[![Python](https://img.shields.io/pypi/pyversions/pyfragment?style=flat&color=blue&label=Python)](https://python.org) </p>
[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=Tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE)
Async Python client for the **[Fragment.com](https://fragment.com)** marketplace API. [![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/)
[![PyPI Downloads](https://static.pepy.tech/personalized-badge/pyfragment?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/pyfragment)
[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org)
[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=green)](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE)
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** [Documentation](https://bohd4nx.gitbook.io/pyfragment/) · [Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
</div> </div>
> **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org). > **Disclaimer:** This project is not affiliated with, endorsed by, or in any way officially connected with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
---
## Installation ## Installation
@@ -23,43 +27,103 @@ Async Python client for the **[Fragment.com](https://fragment.com)** marketplace
pip install pyfragment pip install pyfragment
``` ```
To install the latest unreleased changes from the `dev` branch:
```bash ```bash
# Latest dev build
pip install git+https://github.com/bohd4nx/pyfragment.git@dev pip install git+https://github.com/bohd4nx/pyfragment.git@dev
``` ```
Requires Python 3.10+.
--- ---
## Quick Start ## Configuration
| Parameter | Type | Default | Description |
| ---------------- | ------------- | -------- | -------------------------------------------------------- |
| `seed` | `str` | — | 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"` |
| `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. You can get cookies in two ways:
- **Automatically** (recommended) — install the optional browser extra and use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed:
```bash
pip install "pyfragment[browser]"
```
```python
from pyfragment import get_cookies_from_browser
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
# result.cookies — dict[str, str] to pass to FragmentClient
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
```
- **Manually** — install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or JSON string.
Refresh when you get authentication errors.
**Tonapi key** — generate at [tonconsole.com](https://tonconsole.com).
**Seed phrase** — 24-word mnemonic from your TON wallet (Tonkeeper → Settings → Backup). Never share it.
---
## Usage
```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_API_KEY", api_key="YOUR_TONAPI_KEY",
cookies={"stel_ssid": "...", "stel_dt": "...", "stel_token": "...", "stel_ton_token": "..."}, cookies={
"stel_ssid": "...",
"stel_dt": "...",
"stel_token": "...",
"stel_ton_token": "...",
},
) as client: ) as client:
wallet = await client.get_wallet() wallet = await client.get_wallet()
print("GRAM: %s | USDT: %s" % (wallet.gram_balance, wallet.usdt_balance)) print(f"Wallet: {wallet.address} | TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
stars = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.USDT_GRAM) recipient = "https://t.me/username" # also supports: @username, username
print("Sent %s Stars to %s | tx: %s" % (stars.amount, stars.username, stars.transaction_id))
premium = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM) stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton")
print("Sent Premium %sm to %s | tx: %s" % (premium.amount, premium.username, premium.transaction_id)) print(f"Stars sent: {stars.amount} to {stars.username} | tx: {stars.transaction_id}")
premium = await client.purchase_premium(recipient, months=6, payment_method="ton")
print(f"Premium sent: {premium.amount} months to {premium.username} | tx: {premium.transaction_id}")
asyncio.run(main()) asyncio.run(main())
``` ```
Full runnable examples:
- https://github.com/bohd4nx/pyfragment/tree/master/examples
Payload debug/decode helper (thanks):
- https://ton-cell-abi-viewer.vercel.app/
---
<div align="center"> <div align="center">
[Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) ### Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx)
**Star ⭐ this repo if you found it useful!**
</div> </div>
-19
View File
@@ -1,19 +0,0 @@
# Security Policy
## Reporting a vulnerability
Please **do not** open a public GitHub issue for security vulnerabilities.
Report them privately via GitHub's [Security Advisory](https://github.com/bohd4nx/pyfragment/security/advisories/new) feature, or contact the maintainer directly at [@bohd4nx](https://t.me/bohd4nx) on Telegram.
Include:
- A description of the vulnerability and its potential impact.
- Steps to reproduce or a proof-of-concept.
- Affected versions.
You will receive a response within 72 hours. Once the fix is released, the advisory will be published.
## Scope
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.
+2 -8
View File
@@ -13,7 +13,7 @@ import json
from pyfragment import FragmentClient, GiftsResult from pyfragment import FragmentClient, GiftsResult
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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,13 +33,7 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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):")
+2 -8
View File
@@ -12,7 +12,7 @@ import json
from pyfragment import FragmentClient, NumbersResult from pyfragment import FragmentClient, NumbersResult
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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,7 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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):")
+2 -8
View File
@@ -12,7 +12,7 @@ import json
from pyfragment import FragmentClient, UsernamesResult from pyfragment import FragmentClient, UsernamesResult
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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,7 @@ FILTER = "auction" # "", "auction", "sale", "sold" — or omit
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER) result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):") print(f"Found {len(result.items)} result(s):")
-33
View File
@@ -1,33 +0,0 @@
"""
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()
+2 -8
View File
@@ -14,7 +14,7 @@ import asyncio
from pyfragment import FragmentClient from pyfragment import FragmentClient
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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,13 +33,7 @@ PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragmen
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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)
+4 -6
View File
@@ -1,9 +1,8 @@
""" """
Example: fetch wallet address, state, and separate GRAM (ex TON)/USDT balances. Example: fetch wallet address, state, and separate 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
@@ -11,7 +10,7 @@ import asyncio
from pyfragment import FragmentClient from pyfragment import FragmentClient
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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", ...
@@ -30,13 +29,12 @@ 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", "HighloadV2", "HighloadV3R1" wallet_version="V5R1", # or "V4R2"
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.gram_balance} GRAM (ex TON)") print(f"Balance: {wallet.ton_balance} TON")
print(f"Balance: {wallet.usdt_balance} USDT") print(f"Balance: {wallet.usdt_balance} USDT")
+2 -8
View File
@@ -11,7 +11,7 @@ import asyncio
from pyfragment import AnonymousNumberError, FragmentClient from pyfragment import AnonymousNumberError, FragmentClient
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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,13 +28,7 @@ NUMBER = "+88888888888"
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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:
+7 -13
View File
@@ -1,8 +1,8 @@
""" """
Example: recharge your own Telegram Ads account with GRAM (ex TON). Example: recharge your own Telegram Ads account with TON.
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON). Amount must be an integer between 1 and 1 000 000 000 TON.
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost. Your wallet must satisfy the current minimum 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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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,17 +29,11 @@ COOKIES = {
} }
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
AMOUNT = 10 # 11 000 000 000 GRAM (ex TON) AMOUNT = 10 # 11 000 000 000 TON
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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:
@@ -49,7 +43,7 @@ async def main() -> None:
print(f"Invalid argument: {e}") print(f"Invalid argument: {e}")
return return
print(f"{result.amount} GRAM (ex TON) recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}") print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
if __name__ == "__main__": if __name__ == "__main__":
@@ -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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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,17 +28,11 @@ COOKIES = {
CHANNEL = "https://t.me/channel" CHANNEL = "https://t.me/channel"
WINNERS = 10 # 124 000 WINNERS = 10 # 124 000
MONTHS = 3 # 3, 6 or 12 MONTHS = 3 # 3, 6 or 12
PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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,
@@ -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 15. winners must be an integer between 1 and 5.
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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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,19 +26,13 @@ COOKIES = {
} }
CHANNEL = "https://t.me/channel" CHANNEL = "https://t.me/channel"
WINNERS = 3 # 115 WINNERS = 3 # 15
AMOUNT = 1000 # 5001 000 000 stars per winner AMOUNT = 1000 # 5001 000 000 stars per winner
PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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,
@@ -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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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,17 +27,11 @@ 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 = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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 10 000 000. Amount must be an integer between 50 and 1 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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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,18 +26,12 @@ COOKIES = {
} }
USERNAME = "https://t.me/username" USERNAME = "https://t.me/username"
AMOUNT = 500 # 5010 000 000 stars AMOUNT = 500 # 501 000 000 stars
PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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,10 +1,10 @@
""" """
Example: top up GRAM (ex TON) to a recipient's Telegram balance. Example: top up TON to a recipient's Telegram balance.
For adding GRAM (ex TON) to a Telegram Ads account, use recharge_ads() instead. For adding TON to a Telegram Ads account, use recharge_ads() instead.
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON). Amount must be an integer between 1 and 1 000 000 000 TON.
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost. Your wallet must satisfy the current minimum 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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# 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,19 +31,13 @@ COOKIES = {
} }
USERNAME = "@username" USERNAME = "@username"
AMOUNT = 10 # 11 000 000 000 GRAM (ex TON) AMOUNT = 10 # 11 000 000 000 TON
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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_gram(USERNAME, amount=AMOUNT, show_sender=True) result = await client.topup_ton(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
@@ -54,7 +48,7 @@ async def main() -> None:
print(f"Invalid argument: {e}") print(f"Invalid argument: {e}")
return return
print(f"{result.amount} GRAM (ex TON) successfully topped up for {result.username} | tx: {result.transaction_id}") print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}")
if __name__ == "__main__": if __name__ == "__main__":
-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.3 KiB

+8 -15
View File
@@ -1,15 +1,8 @@
import logging
from importlib.metadata import version from importlib.metadata import version
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult from pyfragment.core.cookies import get_cookies_from_browser
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,
@@ -25,10 +18,13 @@ from pyfragment.exceptions import (
VerificationError, VerificationError,
WalletError, WalletError,
) )
from pyfragment.services.cookies import CookieResult, get_cookies_from_browser from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.services.tonapi.models import WalletInfo from pyfragment.models.cookies import CookieResult
from pyfragment.models.enums import PaymentMethod
logging.getLogger("pyfragment").addHandler(logging.NullHandler()) from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import WalletInfo
__version__: str = version("pyfragment") __version__: str = version("pyfragment")
@@ -54,7 +50,6 @@ __all__ = [
"FragmentAPIError", "FragmentAPIError",
"FragmentPageError", "FragmentPageError",
"ConfigurationError", "ConfigurationError",
"AlreadySubscribedError",
"UserNotFoundError", "UserNotFoundError",
"WalletError", "WalletError",
"VerificationError", "VerificationError",
@@ -66,8 +61,6 @@ __all__ = [
"ParseError", "ParseError",
"UnexpectedError", "UnexpectedError",
# literal types # literal types
"ApiProvider",
"PaymentMethod", "PaymentMethod",
"WalletVersion",
"get_cookies_from_browser", "get_cookies_from_browser",
] ]
+125 -80
View File
@@ -1,29 +1,25 @@
from __future__ import annotations from __future__ import annotations
from typing import Any import json
from typing import Any, cast, get_args
from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL import httpx
from pyfragment.core.validation import (
normalize_provider, from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
normalize_wallet_version, from pyfragment.core.transport import fragment_request, get_fragment_hash
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.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.enums import ApiProvider, PaymentMethod, WalletVersion from pyfragment.domains.tonapi.service import TonapiService
from pyfragment.services.tonapi.models import WalletInfo from pyfragment.exceptions import ConfigurationError, CookieError
from pyfragment.services.tonapi.service import TonapiService from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.models.enums import PaymentMethod, WalletVersion
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import WalletInfo
class FragmentClient: class FragmentClient:
@@ -35,18 +31,14 @@ class FragmentClient:
connected with Fragment or Telegram. connected with Fragment or Telegram.
Args: Args:
seed: 12- or 24-word mnemonic phrase for the GRAM (ex TON) wallet. seed: 24-word mnemonic phrase for the TON wallet.
api_key: API key for the chosen provider — tonconsole.com (default) or t.me/toncenter. api_key: Tonapi API key — get one at https://tonconsole.com.
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``, ``wallet_version``, or ``api_provider`` ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
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::
@@ -67,23 +59,42 @@ class FragmentClient:
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:
validate_credentials(seed, api_key) missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
provider = normalize_provider(api_provider) if missing:
parsed_cookies = parse_cookies(cookies) raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
validate_cookie_keys(parsed_cookies)
version = normalize_wallet_version(wallet_version) 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())))
if isinstance(cookies, str):
try:
cookies = json.loads(cookies)
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict[str, Any], cookies).get(k, "")).strip()]
if missing_keys:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
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)))
)
)
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] = cast(dict[str, Any], cookies)
self.cookies: dict[str, Any] = parsed_cookies self.wallet_version: WalletVersion = version # type: ignore[assignment]
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)
@@ -98,14 +109,14 @@ class FragmentClient:
pass pass
def __repr__(self) -> str: def __repr__(self) -> str:
return f"FragmentClient(wallet_version='{self.wallet_version}', api_provider='{self.api_provider}', cookies={len(self.cookies)} keys)" return f"FragmentClient(wallet_version='{self.wallet_version}', 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 = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> PremiumResult: ) -> PremiumResult:
"""Gift Telegram Premium to a user. """Gift Telegram Premium to a user.
@@ -113,7 +124,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 — defaults to ``PaymentMethod.GRAM``. payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns: Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
@@ -125,40 +136,41 @@ class FragmentClient:
username: str, username: str,
amount: int, amount: int,
show_sender: bool = True, show_sender: bool = True,
payment_method: PaymentMethod = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> 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 ``10 000 000``. amount: Number of stars — integer from ``50`` to ``1 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 — defaults to ``PaymentMethod.GRAM``. payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
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_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up GRAM (ex TON) to a recipient's Telegram balance. """Top up 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 GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``. amount: Amount in 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_gram(username, amount, show_sender=show_sender) return await self.ads.topup_ton(username, amount, show_sender=show_sender)
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account. """Add funds to your own Telegram Ads account.
Args: Args:
account: Channel or bot username the Ads account is linked to (e.g. ``"@mychannel"``). account: Your Fragment Ads account identifier — the channel or bot username
amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``. the Ads account is linked to (e.g. ``"@mychannel"``).
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``.
@@ -169,7 +181,9 @@ 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``, ``state``, ``gram_balance``, and ``usdt_balance``. :class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
(``"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()
@@ -178,18 +192,19 @@ class FragmentClient:
channel: str, channel: str,
winners: int, winners: int,
amount: int, amount: int,
payment_method: PaymentMethod = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> 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 ``15``. winners: Number of winners — integer from ``1`` to ``5``.
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 — defaults to ``PaymentMethod.GRAM``. payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns: Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``. :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
""" """
return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method) return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method)
@@ -198,18 +213,19 @@ class FragmentClient:
channel: str, channel: str,
winners: int, winners: int,
months: int = 3, months: int = 3,
payment_method: PaymentMethod = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> 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 — integer from ``1`` to ``24 000``. winners: Number of winners — positive integer.
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 — defaults to ``PaymentMethod.GRAM``. payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns: Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``. :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
""" """
return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method) return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method)
@@ -217,7 +233,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 ``+``. number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
Returns: Returns:
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending), :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
@@ -244,7 +260,7 @@ class FragmentClient:
:class:`TerminateSessionsResult` with ``number`` and ``message``. :class:`TerminateSessionsResult` with ``number`` and ``message``.
Raises: Raises:
AnonymousNumberError: If the number is not owned or has no active sessions. AnonymousNumberError: If the number is not owned by this account or has no active sessions.
""" """
return await self.anonymous_numbers.terminate_sessions(number) return await self.anonymous_numbers.terminate_sessions(number)
@@ -258,13 +274,17 @@ class FragmentClient:
"""Search the Fragment marketplace for Telegram usernames. """Search the Fragment marketplace for Telegram usernames.
Args: Args:
query: Search text. Omit or pass ``""`` to browse all. query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``. sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available). ``"ending"``. Omit to use Fragment's default ordering.
offset_id: Pass :attr:`UsernamesResult.next_offset_id` to fetch the next page. filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id`
from a previous result to fetch the next page.
Returns: Returns:
:class:`UsernamesResult` with ``items`` and ``next_offset_id``. :class:`UsernamesResult` with ``items`` (parsed list of item dicts)
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)
@@ -278,13 +298,17 @@ class FragmentClient:
"""Search the Fragment marketplace for anonymous Telegram numbers. """Search the Fragment marketplace for anonymous Telegram numbers.
Args: Args:
query: Search text. Omit or pass ``""`` to browse all. query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``. sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available). ``"ending"``. Omit to use Fragment's default ordering.
offset_id: Pass :attr:`NumbersResult.next_offset_id` to fetch the next page. filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id`
from a previous result to fetch the next page.
Returns: Returns:
:class:`NumbersResult` with ``items`` and ``next_offset_id``. :class:`NumbersResult` with ``items`` (parsed list of item dicts)
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)
@@ -301,16 +325,22 @@ class FragmentClient:
"""Search the Fragment gifts marketplace. """Search the Fragment gifts marketplace.
Args: Args:
query: Search text. Omit or pass ``""`` to browse all. query: Search text. Omit or pass ``""`` to browse without filtering by name.
collection: Gift collection slug (e.g. ``"artisanbrick"``). collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``. sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available). ``"ending"``. Omit to use Fragment's default ordering.
view: Active attribute tab name (e.g. ``"Model"``). filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
attr: Attribute filters — e.g. ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}``. ``""`` (available items). Omit to return all.
offset: Pass :attr:`GiftsResult.next_offset` to fetch the next page. view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
attr: Attribute filters — mapping of trait name to accepted values, e.g.
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
Each key is sent as ``attr[Key]`` with its list of values.
offset: Integer page offset from a previous :class:`GiftsResult`.
Pass ``next_offset`` to fetch the next page.
Returns: Returns:
:class:`GiftsResult` with ``items`` and ``next_offset``. :class:`GiftsResult` with ``items`` (parsed list of item dicts)
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
@@ -321,12 +351,27 @@ 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. data: Additional form-data fields to include in the request body.
page_url: Fragment page URL to derive the API hash. Defaults to ``FRAGMENT_BASE_URL``. page_url: Fragment page URL used to derive the API hash and headers.
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, self.headers) headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session:
fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout)
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
+51 -57
View File
@@ -1,10 +1,22 @@
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"
@@ -13,37 +25,29 @@ 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"
DEFAULT_TIMEOUT: float = 30.0 SUPPORTED_BROWSERS: frozenset[str] = frozenset(
{
# Fragment cookie keys required for authenticated API calls "arc",
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") "brave",
"chrome",
BASE_HEADERS: dict[str, str] = { "chromium",
"accept": "application/json, text/javascript, */*; q=0.01", "chromium_based",
"accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", "edge",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8", "firefox",
"origin": FRAGMENT_BASE_URL, "firefox_based",
"priority": "u=1, i", "librewolf",
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"', "opera",
"sec-ch-ua-mobile": "?1", "opera_gx",
"sec-ch-ua-platform": '"Android"', "safari",
"sec-fetch-dest": "empty", "vivaldi",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": (
"Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36"
),
"x-requested-with": "XMLHttpRequest",
} }
)
# USDT-TON jetton master contract address on GRAM (ex TON) mainnet DEVICE: str = json.dumps(
USDT_GRAM_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" {
# TON Connect device info sent during wallet connection handshake
DEVICE_INFO: dict[str, Any] = {
"platform": "iphone", "platform": "iphone",
"appName": "Tonkeeper", "appName": "Tonkeeper",
"appVersion": "26.05.0", "appVersion": "26.04.0",
"maxProtocolVersion": 2, "maxProtocolVersion": 2,
"features": [ "features": [
"SendTransaction", "SendTransaction",
@@ -51,33 +55,23 @@ DEVICE_INFO: dict[str, Any] = {
{"name": "SignData", "types": ["text", "binary", "cell"]}, {"name": "SignData", "types": ["text", "binary", "cell"]},
], ],
} }
)
# Stars: direct purchase per transaction BASE_HEADERS: dict[str, str] = {
STARS_PURCHASE_MIN: int = 50 "accept": "application/json, text/javascript, */*; q=0.01",
STARS_PURCHASE_MAX: int = 10_000_000 "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
# Stars: giveaway amount per winner "origin": FRAGMENT_BASE_URL,
STARS_GIVEAWAY_MIN: int = 500 "priority": "u=1, i",
STARS_GIVEAWAY_MAX: int = 1_000_000 "sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua-mobile": "?1",
# Stars giveaway winner count "sec-ch-ua-platform": '"Android"',
STARS_WINNERS_MIN: int = 1 "sec-fetch-dest": "empty",
STARS_WINNERS_MAX: int = 15 "sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
# Premium giveaway winner count "user-agent": (
PREMIUM_WINNERS_MIN: int = 1 "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
PREMIUM_WINNERS_MAX: int = 24_000 "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
),
# GRAM (ex TON) topup / Ads recharge amount "x-requested-with": "XMLHttpRequest",
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})
@@ -1,26 +1,25 @@
from __future__ import annotations from __future__ import annotations
import importlib import importlib
from datetime import UTC, datetime from datetime import datetime, timezone
from typing import Any from typing import Any
from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
from pyfragment.enums import SupportedBrowser
from pyfragment.exceptions import CookieError from pyfragment.exceptions import CookieError
from pyfragment.services.cookies.models import CookieResult from pyfragment.models.cookies import CookieResult
try: try:
import rookiepy import rookiepy # type: ignore[import-not-found]
except Exception: # noqa: BLE001 except Exception:
rookiepy = None # type: ignore[assignment] rookiepy = None
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult: def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
global rookiepy global rookiepy
key = browser.lower() key = browser.lower()
if not any(key == m for m in SupportedBrowser): if key not in SUPPORTED_BROWSERS:
supported = ", ".join(sorted(b.value for b in SupportedBrowser)) supported = ", ".join(sorted(SUPPORTED_BROWSERS))
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported)) raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
try: try:
@@ -42,11 +41,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=UTC).isoformat() expires_iso = datetime.fromtimestamp(raw, tz=timezone.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=UTC).isoformat() expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc).isoformat()
break break
except ValueError: except ValueError:
continue continue
@@ -54,7 +53,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(UTC): if expires_dt < datetime.now(timezone.utc):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso)) raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
return CookieResult(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)
+38 -16
View File
@@ -5,47 +5,55 @@ import random
import re import re
from typing import Any, cast from typing import Any, cast
from curl_cffi.requests import AsyncSession, Response import httpx
from pyfragment.core.constants import FRAGMENT_BASE_URL from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
from pyfragment.exceptions import FragmentPageError, ParseError from pyfragment.exceptions import FragmentPageError, ParseError, VerificationError
async def get_fragment_hash( async def get_fragment_hash(
session: AsyncSession[Any], cookies: dict[str, Any],
headers: dict[str, str], headers: dict[str, str],
page_url: str, page_url: str,
timeout: float = DEFAULT_TIMEOUT,
) -> str: ) -> str:
# Derive the natural referer: strip the last path segment (e.g. /stars/buy → /stars) page_headers = {
parent_url = page_url.rsplit("/", 1)[0] or FRAGMENT_BASE_URL k: v
for k, v in headers.items()
page_headers = {k: v for k, v in headers.items() if k not in ("content-type", "origin")} if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
page_headers["referer"] = parent_url }
page_headers["x-aj-referer"] = parent_url page_headers.update(
page_headers.pop("x-aj-referer", None) {
page_headers.pop("x-requested-with", None) "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:
response = await session.get(page_url, headers=page_headers) 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: Response, context: str) -> dict[str, Any]: def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
try: try:
return cast(dict[str, Any], response.json()) # type: ignore[no-untyped-call] return cast(dict[str, Any], response.json())
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: AsyncSession[Any], session: httpx.AsyncClient,
fragment_hash: str, fragment_hash: str,
headers: dict[str, str], headers: dict[str, str],
data: dict[str, Any], data: dict[str, Any],
@@ -65,3 +73,17 @@ 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
-58
View File
@@ -1,58 +0,0 @@
from __future__ import annotations
import json
from typing import Any, cast
from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID, REQUIRED_COOKIE_KEYS
from pyfragment.enums import ApiProvider, WalletVersion
from pyfragment.exceptions import ConfigurationError, CookieError
def parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]:
if isinstance(cookies, str):
try:
cookies = json.loads(cookies)
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
return cast(dict[str, Any], cookies)
def validate_cookie_keys(cookies: dict[str, Any]) -> None:
missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
if missing:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing)))
def normalize_provider(api_provider: str) -> ApiProvider:
try:
return ApiProvider(api_provider.strip().lower())
except ValueError:
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_PROVIDER.format(
provider=api_provider,
supported=", ".join(sorted(p.value for p in ApiProvider)),
)
)
def normalize_wallet_version(wallet_version: str) -> WalletVersion:
version = wallet_version.strip().upper()
try:
return WalletVersion(version)
except ValueError:
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_VERSION.format(
version=version,
supported=", ".join(sorted(m.value for m in WalletVersion)),
)
)
def validate_credentials(seed: str, api_key: str) -> None:
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
if missing:
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
word_count = len(seed.split())
if word_count not in MNEMONIC_WORD_COUNTS_VALID:
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
+2 -2
View File
@@ -1,5 +1,5 @@
from pyfragment.domains.ads.recharge import recharge_ads from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.ads.service import AdsService from pyfragment.domains.ads.service import AdsService
from pyfragment.domains.ads.tonup import topup_gram from pyfragment.domains.ads.tonup import topup_ton
__all__ = ["AdsService", "recharge_ads", "topup_gram"] __all__ = ["AdsService", "recharge_ads", "topup_ton"]
-22
View File
@@ -1,22 +0,0 @@
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}')"
+8 -14
View File
@@ -1,25 +1,21 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.domains.ads.models import AdsRechargeResult from pyfragment.domains.tonapi.account import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
from pyfragment.services.tonapi.account import get_account_info from pyfragment.models.payments import AdsRechargeResult
from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
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 (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX): if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT) raise ConfigurationError(ConfigurationError.INVALID_TON_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 +30,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": json.dumps(DEVICE_INFO), "device": DEVICE,
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
}, },
@@ -46,9 +42,7 @@ async def recharge_ads(client: FragmentClient, account: str, amount: int) -> Ads
tx_hash = await process_transaction(client, transaction) tx_hash = await process_transaction(client, transaction)
return AdsRechargeResult(transaction_id=tx_hash, amount=amount) return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
except FragmentError as exc: except FragmentError:
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 GRAM (ex TON) due to an unexpected error", account, amount)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+4 -4
View File
@@ -2,10 +2,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult
from pyfragment.domains.ads.recharge import recharge_ads from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.ads.tonup import topup_gram from pyfragment.domains.ads.tonup import topup_ton
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_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
return await topup_gram(self._client, username, amount, show_sender=show_sender) return await topup_ton(self._client, username, amount, show_sender=show_sender)
+10 -20
View File
@@ -1,12 +1,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
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,
@@ -15,19 +15,15 @@ from pyfragment.exceptions import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
) )
from pyfragment.services.tonapi.account import get_account_info from pyfragment.models.payments import AdsTopupResult
from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__) async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
async def topup_gram(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT)
try: 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 +37,14 @@ async def topup_gram(client: FragmentClient, username: str, amount: int, show_se
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="GRAM (ex TON) topup")) raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="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": json.dumps(DEVICE_INFO), "device": DEVICE,
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
"show_sender": int(show_sender), "show_sender": int(show_sender),
@@ -61,13 +57,7 @@ async def topup_gram(client: FragmentClient, username: str, amount: int, show_se
tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount) tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount)
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount) return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError as exc: except FragmentError:
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 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",
+4 -24
View File
@@ -1,21 +1,17 @@
from __future__ import annotations from __future__ import annotations
import html import html
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
logger = logging.getLogger(__name__)
def _strip_plus(number: str) -> str: def _strip_plus(number: str) -> str:
return number.lstrip("+") if isinstance(number, str) else number return number.lstrip("+") if isinstance(number, str) else number
@@ -36,11 +32,9 @@ async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult
return LoginCodeResult(number=number, code=code, active_sessions=active_sessions) return LoginCodeResult(number=number, code=code, active_sessions=active_sessions)
except FragmentError as exc: except FragmentError:
logger.error("Failed to get login code for number '%s': %s", number, exc, exc_info=True)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to get login code for number '%s' due to an unexpected error", number)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -56,21 +50,9 @@ async def toggle_login_codes(client: FragmentClient, number: str, can_receive: b
if result.get("error"): if result.get("error"):
raise FragmentAPIError(html.unescape(result["error"])) raise FragmentAPIError(html.unescape(result["error"]))
except FragmentError as exc: except FragmentError:
logger.error(
"Failed to toggle login code delivery for number '%s' (can_receive=%s): %s",
number,
can_receive,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to toggle login code delivery for number '%s' (can_receive=%s) due to an unexpected error",
number,
can_receive,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -106,9 +88,7 @@ async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSe
return TerminateSessionsResult(number=number, message=result.get("msg")) return TerminateSessionsResult(number=number, message=result.get("msg"))
except FragmentError as exc: except FragmentError:
logger.error("Failed to terminate sessions for number '%s': %s", number, exc, exc_info=True)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to terminate sessions for number '%s' due to an unexpected error", number)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -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
+1 -32
View File
@@ -1,41 +1,10 @@
from __future__ import annotations from __future__ import annotations
import logging from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from curl_cffi.requests import AsyncSession
from pyfragment.core.constants import BASE_HEADERS
from pyfragment.core.transport import fragment_request, get_fragment_hash
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def raw_api_call(
cookies: dict[str, Any],
timeout: float,
method: str,
data: dict[str, Any] | None,
page_url: str,
headers: dict[str, str] | None = None,
) -> dict[str, Any]:
base = headers if headers is not None else BASE_HEADERS
payload = {"method": method, **(data or {})}
call_headers = {**base, "referer": page_url, "x-aj-referer": page_url}
logger.debug("Starting Fragment API call '%s' on %s", method, page_url)
try:
async with AsyncSession(cookies=cookies, timeout=timeout, impersonate="chrome") as session:
fragment_hash = await get_fragment_hash(session, call_headers, page_url)
response = await fragment_request(session, fragment_hash, call_headers, payload)
logger.debug("Completed Fragment API call '%s' with response keys: %s", method, sorted(response.keys()))
return response
except Exception:
logger.exception("Failed to call Fragment API method '%s' on %s", method, page_url)
raise
class BaseService: class BaseService:
def __init__(self, client: FragmentClient) -> None: def __init__(self, client: FragmentClient) -> None:
+1 -1
View File
@@ -1,6 +1,6 @@
from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.domains.giveaways.service import GiveawaysService from pyfragment.domains.giveaways.service import GiveawaysService
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
__all__ = [ __all__ = [
"GiveawaysService", "GiveawaysService",
+20 -100
View File
@@ -1,25 +1,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging from typing import TYPE_CHECKING, get_args
import random
from typing import TYPE_CHECKING
from pyfragment.core.constants import ( from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
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.enums import PaymentMethod 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,
@@ -28,37 +15,29 @@ from pyfragment.exceptions import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
) )
from pyfragment.services.tonapi.account import get_account_info from pyfragment.models.enums import PaymentMethod
from pyfragment.services.tonapi.transaction import process_transaction from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
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 = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> StarsGiveawayResult: ) -> StarsGiveawayResult:
if not isinstance(winners, int) or not (STARS_WINNERS_MIN <= winners <= STARS_WINNERS_MAX): if not isinstance(winners, int) or not (1 <= winners <= 5):
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS) raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
if not isinstance(amount, int) or not (STARS_GIVEAWAY_MIN <= amount <= STARS_GIVEAWAY_MAX): if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER) raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
if not any(payment_method == m for m in PaymentMethod): if payment_method not in get_args(PaymentMethod):
raise ConfigurationError( raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format( ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method, method=payment_method,
supported=", ".join(sorted(m.value for m in PaymentMethod)), supported=", ".join(sorted(get_args(PaymentMethod))),
) )
) )
@@ -68,17 +47,6 @@ async def giveaway_stars(
if not recipient: if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
await client.call(
"updateStarsGiveawayState",
{"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,
)
result = await client.call( result = await client.call(
"initGiveawayStarsRequest", "initGiveawayStarsRequest",
{ {
@@ -99,7 +67,7 @@ async def giveaway_stars(
"getGiveawayStarsLink", "getGiveawayStarsLink",
{ {
"account": json.dumps(account), "account": json.dumps(account),
"device": json.dumps(DEVICE_INFO), "device": DEVICE,
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
}, },
@@ -116,25 +84,9 @@ async def giveaway_stars(
) )
return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount) return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount)
except FragmentError as exc: except FragmentError:
logger.error(
"Failed to run Stars giveaway for channel '%s' (winners=%s, amount=%s, payment_method='%s'): %s",
channel,
winners,
amount,
payment_method,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to run Stars giveaway for channel '%s' (winners=%s, amount=%s, payment_method='%s') due to an unexpected error",
channel,
winners,
amount,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -143,17 +95,17 @@ async def giveaway_premium(
channel: str, channel: str,
winners: int, winners: int,
months: int = 3, months: int = 3,
payment_method: PaymentMethod = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> PremiumGiveawayResult: ) -> PremiumGiveawayResult:
if not isinstance(winners, int) or not (PREMIUM_WINNERS_MIN <= winners <= PREMIUM_WINNERS_MAX): if not isinstance(winners, int) or not (1 <= winners <= 24_000):
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM) raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
if months not in PREMIUM_MONTHS_VALID: if months not in (3, 6, 12):
raise ConfigurationError(ConfigurationError.INVALID_MONTHS) raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
if not any(payment_method == m for m in PaymentMethod): if payment_method not in get_args(PaymentMethod):
raise ConfigurationError( raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format( ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method, method=payment_method,
supported=", ".join(sorted(m.value for m in PaymentMethod)), supported=", ".join(sorted(get_args(PaymentMethod))),
) )
) )
@@ -167,22 +119,6 @@ async def giveaway_premium(
if not recipient: if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
await client.call(
"updatePremiumGiveawayState",
{
"mode": "new",
"lv": "false",
"dh": _state_nonce(),
"quantity": "",
},
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",
{ {
@@ -203,7 +139,7 @@ async def giveaway_premium(
"getGiveawayPremiumLink", "getGiveawayPremiumLink",
{ {
"account": json.dumps(account), "account": json.dumps(account),
"device": json.dumps(DEVICE_INFO), "device": DEVICE,
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
}, },
@@ -220,23 +156,7 @@ async def giveaway_premium(
) )
return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months) return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months)
except FragmentError as exc: except FragmentError:
logger.error(
"Failed to run Premium giveaway for channel '%s' (winners=%s, months=%s, payment_method='%s'): %s",
channel,
winners,
months,
payment_method,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to run Premium giveaway for channel '%s' (winners=%s, months=%s, payment_method='%s') due to an unexpected error",
channel,
winners,
months,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+4 -4
View File
@@ -4,8 +4,8 @@ from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService from pyfragment.domains.base import BaseService
from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult from pyfragment.models.enums import PaymentMethod
from pyfragment.enums import PaymentMethod from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
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 = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> 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 = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> PremiumGiveawayResult: ) -> PremiumGiveawayResult:
return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method) return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method)
+1 -1
View File
@@ -1,6 +1,6 @@
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
from pyfragment.domains.marketplace.service import MarketplaceService from pyfragment.domains.marketplace.service import MarketplaceService
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
__all__ = [ __all__ = [
"GiftsResult", "GiftsResult",
+4 -40
View File
@@ -1,20 +1,16 @@
from __future__ import annotations from __future__ import annotations
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
logger = logging.getLogger(__name__)
async def search_usernames( async def search_usernames(
client: FragmentClient, client: FragmentClient,
query: str = "", query: str = "",
@@ -40,19 +36,9 @@ async def search_usernames(
next_offset_id = str(raw_noi) if raw_noi else None next_offset_id = str(raw_noi) if raw_noi else None
return UsernamesResult(items=items, next_offset_id=next_offset_id) return UsernamesResult(items=items, next_offset_id=next_offset_id)
except FragmentError as exc: except FragmentError:
logger.error(
"Failed to search usernames (query='%s', sort='%s', filter='%s', offset_id='%s'): %s",
query,
sort,
filter,
offset_id,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to search usernames for query '%s' due to an unexpected error", query)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -81,19 +67,9 @@ async def search_numbers(
next_offset_id = str(raw_noi) if raw_noi else None next_offset_id = str(raw_noi) if raw_noi else None
return NumbersResult(items=items, next_offset_id=next_offset_id) return NumbersResult(items=items, next_offset_id=next_offset_id)
except FragmentError as exc: except FragmentError:
logger.error(
"Failed to search numbers (query='%s', sort='%s', filter='%s', offset_id='%s'): %s",
query,
sort,
filter,
offset_id,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to search numbers for query '%s' due to an unexpected error", query)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -130,19 +106,7 @@ async def search_gifts(
items, next_offset = parse_gift_items(result.get("html") or "") items, next_offset = parse_gift_items(result.get("html") or "")
return GiftsResult(items=items, next_offset=next_offset) return GiftsResult(items=items, next_offset=next_offset)
except FragmentError as exc: except FragmentError:
logger.error(
"Failed to search gifts (query='%s', collection='%s', sort='%s', filter='%s', view='%s', offset='%s'): %s",
query,
collection,
sort,
filter,
view,
offset,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to search gifts for query '%s' due to an unexpected error", query)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+1 -1
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService from pyfragment.domains.base import BaseService
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
+1 -1
View File
@@ -1,5 +1,5 @@
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars
from pyfragment.domains.purchases.service import PurchasesService from pyfragment.domains.purchases.service import PurchasesService
from pyfragment.models.payments import PremiumResult, StarsResult
__all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"] __all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"]
+21 -73
View File
@@ -1,23 +1,14 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging import time
import random from typing import TYPE_CHECKING, get_args
from typing import TYPE_CHECKING
from pyfragment.core.constants import ( from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE
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.purchases.models import PremiumResult, StarsResult 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 (
AlreadySubscribedError,
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
FragmentError, FragmentError,
@@ -25,49 +16,39 @@ from pyfragment.exceptions import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
) )
from pyfragment.services.tonapi.account import get_account_info from pyfragment.models.enums import PaymentMethod
from pyfragment.services.tonapi.transaction import process_transaction from pyfragment.models.payments import PremiumResult, StarsResult
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
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 = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> StarsResult: ) -> StarsResult:
if not isinstance(amount, int) or not (STARS_PURCHASE_MIN <= amount <= STARS_PURCHASE_MAX): if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
if not any(payment_method == m for m in PaymentMethod): if payment_method not in get_args(PaymentMethod):
raise ConfigurationError( raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format( ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method, method=payment_method,
supported=", ".join(sorted(m.value for m in PaymentMethod)), supported=", ".join(sorted(get_args(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": _state_nonce()}, {"mode": "new", "lv": "false", "dh": str(int(time.time()))},
page_url=STARS_PAGE, page_url=STARS_PAGE,
) )
result = await client.call( result = await client.call(
@@ -85,7 +66,7 @@ async def purchase_stars(
"getBuyStarsLink", "getBuyStarsLink",
{ {
"account": json.dumps(account), "account": json.dumps(account),
"device": json.dumps(DEVICE_INFO), "device": DEVICE,
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
"show_sender": int(show_sender), "show_sender": int(show_sender),
@@ -103,23 +84,9 @@ async def purchase_stars(
) )
return StarsResult(transaction_id=tx_hash, username=username, amount=amount) return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError as exc: except FragmentError:
logger.error(
"Failed to purchase %s Stars for user '%s' using '%s': %s",
amount,
username,
payment_method,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to purchase %s Stars for user '%s' using '%s' due to an unexpected error",
amount,
username,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
@@ -128,29 +95,27 @@ async def purchase_premium(
username: str, username: str,
months: int, months: int,
show_sender: bool = True, show_sender: bool = True,
payment_method: PaymentMethod = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> PremiumResult: ) -> PremiumResult:
if months not in PREMIUM_MONTHS_VALID: if months not in (3, 6, 12):
raise ConfigurationError(ConfigurationError.INVALID_MONTHS) raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
if not any(payment_method == m for m in PaymentMethod): if payment_method not in get_args(PaymentMethod):
raise ConfigurationError( raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format( ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method, method=payment_method,
supported=", ".join(sorted(m.value for m in PaymentMethod)), supported=", ".join(sorted(get_args(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": _state_nonce()}, {"mode": "new", "lv": "false", "dh": str(int(time.time()))},
page_url=PREMIUM_PAGE, page_url=PREMIUM_PAGE,
) )
result = await client.call( result = await client.call(
@@ -158,9 +123,6 @@ 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:
@@ -171,7 +133,7 @@ async def purchase_premium(
"getGiftPremiumLink", "getGiftPremiumLink",
{ {
"account": json.dumps(account), "account": json.dumps(account),
"device": json.dumps(DEVICE_INFO), "device": DEVICE,
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
"show_sender": int(show_sender), "show_sender": int(show_sender),
@@ -189,21 +151,7 @@ async def purchase_premium(
) )
return PremiumResult(transaction_id=tx_hash, username=username, amount=months) return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
except FragmentError as exc: except FragmentError:
logger.error(
"Failed to purchase %s months of Premium for user '%s' using '%s': %s",
months,
username,
payment_method,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to purchase %s months of Premium for user '%s' using '%s' due to an unexpected error",
months,
username,
payment_method,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+4 -4
View File
@@ -3,9 +3,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService from pyfragment.domains.base import BaseService
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars
from pyfragment.enums import PaymentMethod from pyfragment.models.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 = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> 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 = PaymentMethod.GRAM, payment_method: PaymentMethod = "ton",
) -> 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)
+23
View File
@@ -0,0 +1,23 @@
from pyfragment.domains.tonapi.account import (
check_ton_payment_balance,
check_usdt_payment_balance,
get_account_info,
get_usdt_balance,
get_wallet_info,
)
from pyfragment.domains.tonapi.service import TonapiService
from pyfragment.domains.tonapi.transaction import clean_decode, process_transaction
from pyfragment.domains.tonapi.transfer import send_ton_transfer, send_usdt_transfer
__all__ = [
"TonapiService",
"clean_decode",
"check_ton_payment_balance",
"check_usdt_payment_balance",
"get_account_info",
"get_usdt_balance",
"get_wallet_info",
"process_transaction",
"send_ton_transfer",
"send_usdt_transfer",
]
@@ -1,39 +1,27 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
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, ToncenterClient from tonutils.clients import TonapiClient
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_GRAM_BALANCE, MIN_USDT_BALANCE, USDT_GRAM_MASTER_ADDRESS from pyfragment.core.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS, WALLET_CLASSES
from pyfragment.enums import WALLET_CLASSES, ApiProvider
from pyfragment.exceptions import WalletError from pyfragment.exceptions import WalletError
from pyfragment.services.tonapi.models import WalletInfo from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
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 GRAM (ex TON) wallet.""" """Return the USDT balance for a Fragment-linked 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_GRAM_MASTER_ADDRESS, address=USDT_TON_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)
@@ -41,65 +29,46 @@ async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
return float(raw_balance) / 1_000_000.0 return float(raw_balance) / 1_000_000.0
except ProviderResponseError as exc: except ProviderResponseError as exc:
if exc.code == 404: if exc.code == 404:
logger.debug("No USDT jetton wallet found for '%s'; treating balance as 0", wallet_address)
return 0.0 return 0.0
logger.error("Failed to load USDT balance for wallet '%s': %s", wallet_address, exc, exc_info=True)
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
except Exception as exc: except Exception as exc:
logger.exception("Failed to load USDT balance for wallet '%s' due to an unexpected error", wallet_address)
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_gram_payment_balance( async def check_ton_payment_balance(
balance_gram: float, balance_ton: float,
amount_gram: float, amount_ton: float,
required_payment_amount: float | None, required_payment_amount: float | None,
) -> None: ) -> None:
"""Validate that the GRAM (ex TON) wallet can cover a GRAM (ex TON)-denominated payment.""" """Validate that the TON wallet can cover a TON-denominated payment."""
tx_price_gram = amount_gram tx_price_ton = amount_ton
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_gram = max(tx_price_gram, required_payment_amount) tx_price_ton = max(tx_price_ton, required_payment_amount)
required_gram = max(tx_price_gram, MIN_GRAM_BALANCE) required_ton = max(tx_price_ton, MIN_TON_BALANCE)
if balance_gram < required_gram: if balance_ton < required_ton:
logger.error( raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton))
"Failed GRAM (ex TON) balance check: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)",
round(balance_gram, 6),
round(required_gram, 6),
)
raise WalletError(WalletError.LOW_GRAM_BALANCE.format(balance=balance_gram, required=required_gram))
async def check_usdt_payment_balance( async def check_usdt_payment_balance(
balance_gram: float, balance_ton: 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_gram < MIN_GRAM_BALANCE: if balance_ton < MIN_TON_BALANCE:
logger.error( raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE))
"Failed GRAM (ex TON) gas reserve check for USDT payment: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)",
round(balance_gram, 6),
MIN_GRAM_BALANCE,
)
raise WalletError(WalletError.LOW_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
if usdt_balance < required_usdt: if usdt_balance < required_usdt:
logger.error(
"Failed USDT balance check for wallet '%s': balance=%s USDT, required=%s USDT",
wallet_address,
round(usdt_balance, 6),
round(required_usdt, 6),
)
raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt)) raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt))
async def get_account_info(client: FragmentClient) -> dict[str, Any]: 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 _make_ton_client(client) as ton: async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) 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)
@@ -111,13 +80,12 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"walletStateInit": base64.b64encode(boc).decode(), "walletStateInit": base64.b64encode(boc).decode(),
} }
except Exception as exc: except Exception as exc:
logger.exception("Failed to build Fragment account info from the configured wallet")
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
async def get_wallet_info(client: FragmentClient) -> WalletInfo: async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Fetch the wallet address, chain state, and GRAM (ex TON)/USDT balances.""" """Fetch the wallet address, chain state, and TON/USDT balances."""
async with _make_ton_client(client) as ton: async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) 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)
@@ -127,9 +95,8 @@ 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,
gram_balance=round(wallet.balance / 1_000_000_000, 4), ton_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:
logger.exception("Failed to fetch wallet info from Tonapi")
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
@@ -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.services.tonapi.account import get_wallet_info from pyfragment.domains.tonapi.account import get_wallet_info
from pyfragment.services.tonapi.models import WalletInfo from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING: if TYPE_CHECKING:
pass pass
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import asyncio
import base64
import random
import ssl
from typing import TYPE_CHECKING, Any
from ton_core import Cell, NetworkGlobalID
from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import WALLET_CLASSES
from pyfragment.domains.tonapi.account import check_ton_payment_balance, check_usdt_payment_balance
from pyfragment.exceptions import ParseError, TransactionError, WalletError
from pyfragment.models.enums import PaymentMethod
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
def clean_decode(payload: str) -> str | Cell:
"""Decode a base64 BOC comment from Fragment into text when possible.
Some Fragment payloads are plain text comments, while others are structured
TON messages such as jetton transfers. Non-text payloads are returned as a
`Cell` so the caller can keep the raw binary structure.
"""
s = payload.strip()
if not s:
return ""
s += "=" * (-len(s) % 4)
try:
boc = base64.b64decode(s, altchars=b"-_", validate=True)
cell = Cell.one_from_boc(boc)
sl = cell.begin_parse()
op = sl.load_uint(32)
if op != 0:
# Non-zero op code means this is a structured TON message, not a plain text comment.
return cell
try:
return sl.load_snake_string().strip()
except UnicodeDecodeError:
return cell
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
async def process_transaction(
client: FragmentClient,
transaction_data: dict[str, Any],
payment_method: PaymentMethod = "ton",
required_payment_amount: float | None = None,
) -> str:
"""Sign and broadcast a Fragment transaction with the seeded TON wallet.
Args:
client: Authenticated `FragmentClient` instance.
transaction_data: Raw Fragment transaction payload returned by the API.
payment_method: Payment currency to use for the purchase flow.
required_payment_amount: Optional amount returned by Fragment's init request.
Returns:
Normalized transaction hash string.
"""
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
raise TransactionError(TransactionError.INVALID_PAYLOAD)
message = transaction_data["transaction"]["messages"][0]
amount_ton = int(message["amount"]) / 1_000_000_000
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
# Check balance first so we fail before trying to broadcast on-chain.
try:
await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000
if payment_method == "ton":
wallet.address.to_str(False, False)
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
else:
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
except WalletError:
raise
except Exception as exc:
raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
try:
raw_payload = str(message.get("payload", ""))
payload = clean_decode(raw_payload)
for attempt in range(3):
try:
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
return str(result.normalized_hash)
except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0:
await asyncio.sleep(1 + random.uniform(0, 0.5))
continue
if exc.code == 406 and "seqno" in str(exc).lower():
if attempt < 2:
await asyncio.sleep(2 + random.uniform(0, 1))
continue
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
raise
except (WalletError, TransactionError):
raise
except Exception as exc:
cause: BaseException | None = exc
while cause is not None:
if isinstance(cause, ssl.SSLError):
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
cause = cause.__cause__ or cause.__context__
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from ton_core import Address, NetworkGlobalID
from tonutils.clients import ToncenterClient
from tonutils.contracts import JettonTransferBuilder, TONTransferBuilder
from pyfragment.core.constants import USDT_TON_MASTER_ADDRESS, WALLET_CLASSES
from pyfragment.exceptions import TransactionError, WalletError
from pyfragment.models.wallet import TonTransferResult, UsdtTransferResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def send_ton_transfer(
client: FragmentClient,
destination: str,
amount: int,
body: str | None = None,
) -> TonTransferResult:
"""Send a direct TON transfer from the seeded wallet.
Args:
client: Authenticated `FragmentClient` instance.
destination: Recipient address in any TON-compatible format.
amount: Amount in nanotons.
body: Optional on-chain comment.
"""
try:
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
result = await wallet.transfer_message(
TONTransferBuilder(
destination=Address(destination),
amount=amount,
body=body,
)
)
return TonTransferResult(
transaction_id=str(result.normalized_hash),
destination=destination,
amount=amount,
)
except (TransactionError, WalletError):
raise
except Exception as exc:
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
async def send_usdt_transfer(
client: FragmentClient,
destination: str,
usdt_amount: int,
forward_payload: str | None = None,
ton_for_gas: int = 50_000_000,
) -> UsdtTransferResult:
"""Send a direct USDT transfer from the seeded wallet.
Args:
client: Authenticated `FragmentClient` instance.
destination: Recipient address in any TON-compatible format.
usdt_amount: Amount in USDT base units (6 decimals).
forward_payload: Optional comment passed through to the recipient.
ton_for_gas: TON attached for gas in nanotons.
"""
try:
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
result = await wallet.transfer_message(
JettonTransferBuilder(
destination=Address(destination),
jetton_amount=usdt_amount,
jetton_master_address=Address(USDT_TON_MASTER_ADDRESS),
forward_payload=forward_payload,
forward_amount=1,
amount=ton_for_gas,
)
)
return UsdtTransferResult(
transaction_id=str(result.normalized_hash),
destination=destination,
amount=usdt_amount,
)
except (TransactionError, WalletError):
raise
except Exception as exc:
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
-54
View File
@@ -1,54 +0,0 @@
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"
+20 -47
View File
@@ -1,20 +1,5 @@
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."""
@@ -29,22 +14,20 @@ 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 = f"Invalid mnemonic phrase: expected {', '.join(str(n) for n in sorted(MNEMONIC_WORD_COUNTS_VALID))} words, got {{count}}." INVALID_MNEMONIC = "Invalid mnemonic phrase: expected 12, 18, or 24 words, got {count}."
UNSUPPORTED_PROVIDER = "Unsupported API provider '{provider}'. Supported values: {supported}." INVALID_API_KEY = (
INVALID_MONTHS = f"Invalid Premium duration: choose {', '.join(str(m) for m in sorted(PREMIUM_MONTHS_VALID))} months." "Invalid Tonapi API key: expected at least 68 characters, got {length}. Get a key at https://tonconsole.com."
INVALID_STARS_AMOUNT = (
f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}."
) )
INVALID_GRAM_AMOUNT = f"Invalid GRAM (ex TON) amount: must be an integer between {GRAM_TOPUP_MIN:,} and {GRAM_TOPUP_MAX:,}." INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months."
INVALID_WINNERS_STARS = ( INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1,000,000."
f"Invalid winners count: must be an integer between {STARS_WINNERS_MIN:,} and {STARS_WINNERS_MAX:,}." INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1,000,000,000."
) INVALID_USERNAME = (
INVALID_WINNERS_PREMIUM = ( "Invalid username '{username}'. "
f"Invalid winners count: must be an integer between {PREMIUM_WINNERS_MIN:,} and {PREMIUM_WINNERS_MAX:,}." "Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)."
)
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}."
@@ -62,7 +45,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 GRAM (ex TON) wallet in {browser}." "Make sure you are logged in to {url} and have connected your 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."
@@ -86,13 +69,6 @@ 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):
@@ -103,7 +79,7 @@ class AnonymousNumberError(FragmentAPIError):
class TransactionError(FragmentAPIError): class TransactionError(FragmentAPIError):
"""Raised when a GRAM (ex TON) transaction fails to build or broadcast.""" """Raised when a 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}"
@@ -115,7 +91,7 @@ class TransactionError(FragmentAPIError):
"located in your Python installation folder." "located in your Python installation folder."
) )
DUPLICATE_SEQNO = ( DUPLICATE_SEQNO = (
"Transaction broadcast failed: the GRAM (ex TON) wallet rejected the message " "Transaction broadcast failed: the 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."
@@ -142,16 +118,14 @@ class OperationError(FragmentError):
class WalletError(OperationError): class WalletError(OperationError):
"""Raised for GRAM (ex TON) wallet issues (connection, balance, account info).""" """Raised for TON wallet issues (connection, balance, account info)."""
LOW_GRAM_BALANCE = ( LOW_TON_BALANCE = "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required."
"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."
GRAM_BALANCE_CHECK_FAILED = "Failed to fetch GRAM (ex TON) balance: {exc}" TON_BALANCE_CHECK_FAILED = "Failed to fetch 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 GRAM (ex TON) network: {exc}" ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}"
WALLET_INFO_FAILED = "Failed to retrieve wallet info from GRAM (ex TON) network: {exc}" WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
class UnexpectedError(OperationError): class UnexpectedError(OperationError):
@@ -168,7 +142,6 @@ __all__ = [
"FragmentAPIError", "FragmentAPIError",
"FragmentPageError", "FragmentPageError",
"AnonymousNumberError", "AnonymousNumberError",
"AlreadySubscribedError",
"UserNotFoundError", "UserNotFoundError",
"TransactionError", "TransactionError",
"ParseError", "ParseError",
+27
View File
@@ -0,0 +1,27 @@
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.models.cookies import CookieResult
from pyfragment.models.enums import PaymentMethod, WalletVersion
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
from pyfragment.models.wallet import TonTransferResult, UsdtTransferResult, WalletInfo
__all__ = [
"AdsRechargeResult",
"AdsTopupResult",
"CookieResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
"PaymentMethod",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult",
"TonTransferResult",
"UsernamesResult",
"UsdtTransferResult",
"WalletInfo",
"WalletVersion",
]
@@ -21,3 +21,6 @@ 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"]
@@ -10,3 +10,6 @@ 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"]
+8
View File
@@ -0,0 +1,8 @@
from __future__ import annotations
from typing import Literal
PaymentMethod = Literal["ton", "usdt_ton"]
WalletVersion = Literal["V4R2", "V5R1"]
__all__ = ["PaymentMethod", "WalletVersion"]
@@ -29,3 +29,6 @@ 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"]
@@ -29,3 +29,6 @@ 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"]
@@ -21,3 +21,25 @@ 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"]
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class WalletInfo:
address: str
state: str
ton_balance: float
usdt_balance: float
def __repr__(self) -> str:
return (
f"WalletInfo(address='{self.address}', state='{self.state}', "
f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)"
)
@dataclass
class TonTransferResult:
transaction_id: str
destination: str
amount: int
def __repr__(self) -> str:
return f"TonTransferResult(destination='{self.destination}', amount={self.amount} TON, tx='{self.transaction_id}')"
@dataclass
class UsdtTransferResult:
transaction_id: str
destination: str
amount: int
def __repr__(self) -> str:
return f"UsdtTransferResult(destination='{self.destination}', amount={self.amount} USDT, tx='{self.transaction_id}')"
__all__ = ["TonTransferResult", "UsdtTransferResult", "WalletInfo"]
-3
View File
@@ -1,3 +0,0 @@
from pyfragment.services.cookies import CookieResult, get_cookies_from_browser
__all__ = ["CookieResult", "get_cookies_from_browser"]
-4
View File
@@ -1,4 +0,0 @@
from pyfragment.services.cookies.models import CookieResult
from pyfragment.services.cookies.service import get_cookies_from_browser
__all__ = ["CookieResult", "get_cookies_from_browser"]
-5
View File
@@ -1,5 +0,0 @@
from pyfragment.services.tonapi.service import TonapiService
__all__ = [
"TonapiService",
]
-17
View File
@@ -1,17 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class WalletInfo:
address: str
state: str
gram_balance: float
usdt_balance: float
def __repr__(self) -> str:
return (
f"WalletInfo(address='{self.address}', state='{self.state}', "
f"gram_balance={self.gram_balance} GRAM (ex TON), usdt_balance={self.usdt_balance} USDT)"
)
-168
View File
@@ -1,168 +0,0 @@
from __future__ import annotations
import asyncio
import base64
import logging
import random
import ssl
from typing import TYPE_CHECKING, Any
from ton_core import Cell
from tonutils.exceptions import ProviderResponseError
from pyfragment.enums import WALLET_CLASSES, PaymentMethod
from pyfragment.exceptions import ParseError, TransactionError, WalletError
from pyfragment.services.tonapi.account import _make_ton_client, check_gram_payment_balance, check_usdt_payment_balance
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
def clean_decode(payload: str) -> str | Cell:
"""Decode a base64 BOC comment from Fragment into text when possible.
Some Fragment payloads are plain text comments, while others are structured
GRAM (ex TON) messages such as jetton transfers. Non-text payloads are returned as a
`Cell` so the caller can keep the raw binary structure.
"""
s = payload.strip()
if not s:
return ""
s += "=" * (-len(s) % 4)
try:
boc = base64.b64decode(s, altchars=b"-_", validate=True)
cell = Cell.one_from_boc(boc)
sl = cell.begin_parse()
op = sl.load_uint(32)
if op != 0:
# Non-zero op code means this is a structured GRAM (ex TON) message, not a plain text comment.
return cell
try:
return sl.load_snake_string().strip()
except UnicodeDecodeError:
return cell
except Exception as exc:
logger.exception("Failed to decode Fragment payload")
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
def _extract_message(transaction_data: dict[str, Any]) -> dict[str, Any]:
"""Validate and extract the first message from a Fragment transaction payload."""
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
logger.error("Failed to process transaction: missing transaction payload or messages")
raise TransactionError(TransactionError.INVALID_PAYLOAD)
result: dict[str, Any] = transaction_data["transaction"]["messages"][0]
return result
async def _check_payment_balances(
wallet: Any,
payment_method: PaymentMethod,
amount_gram: float,
required_payment_amount: float | None,
transaction_data: dict[str, Any],
ton: Any,
) -> None:
"""Refresh wallet and verify sufficient balance before broadcasting."""
try:
await wallet.refresh()
balance_gram = wallet.balance / 1_000_000_000
if payment_method == "ton":
await check_gram_payment_balance(balance_gram, amount_gram, required_payment_amount)
else:
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_gram, required_payment_amount, ton, fragment_wallet_address)
except WalletError:
raise
except Exception as exc:
logger.exception("Failed to validate balances before broadcasting transaction")
raise WalletError(WalletError.GRAM_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: str | Cell) -> str:
"""Attempt to broadcast a transaction up to 3 times, handling rate-limit and seqno errors."""
for attempt in range(3):
try:
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanograms, not GRAM (ex TON)
body=payload,
)
return str(result.normalized_hash)
except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0:
logger.warning(
"Broadcast rate-limited (429), retrying transaction once: %s",
exc,
exc_info=True,
)
await asyncio.sleep(1 + random.uniform(0, 0.5))
continue
if exc.code == 406 and "seqno" in str(exc).lower():
if attempt < 2:
logger.warning(
"Broadcast seqno conflict (406), retrying attempt %s: %s",
attempt + 2,
exc,
exc_info=True,
)
await asyncio.sleep(2 + random.uniform(0, 1))
continue
logger.error("Failed to broadcast transaction after seqno retries")
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
raise
logger.error("Failed to broadcast transaction: transfer loop exited without result")
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
async def process_transaction(
client: FragmentClient,
transaction_data: dict[str, Any],
payment_method: PaymentMethod = PaymentMethod.GRAM,
required_payment_amount: float | None = None,
) -> str:
"""Sign and broadcast a Fragment transaction with the seeded GRAM (ex TON) wallet.
Args:
client: Authenticated `FragmentClient` instance.
transaction_data: Raw Fragment transaction payload returned by the API.
payment_method: Payment currency to use for the purchase flow.
required_payment_amount: Optional amount returned by Fragment's init request.
Returns:
Normalized transaction hash string.
"""
message = _extract_message(transaction_data)
amount_gram = int(message["amount"]) / 1_000_000_000
async with _make_ton_client(client) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await _check_payment_balances(wallet, payment_method, amount_gram, required_payment_amount, transaction_data, ton)
payload = clean_decode(str(message.get("payload", "")))
try:
return await _broadcast_with_retry(wallet, message, payload)
except (WalletError, TransactionError):
raise
except Exception as exc:
cause: BaseException | None = exc
while cause is not None:
if isinstance(cause, ssl.SSLError):
logger.exception("Failed to broadcast transaction due to SSL error")
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
cause = cause.__cause__ or cause.__context__
logger.exception(
"Failed to broadcast transaction to '%s' for %s nanograms using payment method '%s'",
message["address"],
message["amount"],
payment_method,
)
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
+35 -25
View File
@@ -4,43 +4,64 @@ build-backend = "hatchling.build"
[project] [project]
name = "pyfragment" name = "pyfragment"
version = "2026.3.3" version = "2026.3.0"
description = "Async Python client for the Fragment.com marketplace API." description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings."
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
requires-python = ">=3.11" requires-python = ">=3.10"
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",
"tonkeeper",
"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",
"Intended Audience :: Financial and Insurance Industry",
"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.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",
"Topic :: Office/Business :: Financial",
"Topic :: Office/Business :: Financial :: Investment",
"Typing :: Typed", "Typing :: Typed",
] ]
dependencies = ["curl-cffi>=0.14.0", "tonutils>=2.0.1"] dependencies = ["httpx>=0.25", "tonutils>=2.0.1"]
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"] dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
browser = ["rookiepy>=0.5.6"] 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"
@@ -57,31 +78,20 @@ addopts = "-v --tb=short"
[tool.ruff] [tool.ruff]
line-length = 128 line-length = 128
target-version = "py311" target-version = "py312"
[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"]
ignore = ["E501"] # E501 — line too long (covered by line-length above)
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
ignore = ["E501", "UP017"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"] "tests/*" = ["E402"]
"systests/*" = ["E402"]
[tool.mypy] [tool.mypy]
python_version = "3.11" python_version = "3.10"
strict = true strict = true
exclude = ["^examples/", "^logs/", "^tests/"] exclude = ["^systests/", "^examples/"]
[[tool.mypy.overrides]]
module = [
"rookiepy",
"ton_core",
"tonutils",
"tonutils.*",
]
ignore_missing_imports = true
[[tool.mypy.overrides]]
# rookiepy = None assignment is valid when the package is installed (type: ModuleType)
# but mypy on CI (without rookiepy) sees it as Any and flags the comment as unused.
module = "pyfragment.services.cookies.service"
warn_unused_ignores = false
+4 -4
View File
@@ -8,7 +8,7 @@ import pytest
from ton_core import Cell from ton_core import Cell
from pyfragment import ParseError from pyfragment import ParseError
from pyfragment.services.tonapi.transaction import clean_decode from pyfragment.domains.tonapi.transaction import clean_decode
PAYLOAD_CASES = [ PAYLOAD_CASES = [
pytest.param( pytest.param(
@@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None:
raw = b"\xfb\xef\xff\x00" raw = b"\xfb\xef\xff\x00"
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=") payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked: with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
result = clean_decode(payload) result = clean_decode(payload)
mocked.assert_called_once_with(raw) mocked.assert_called_once_with(raw)
@@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None:
return _FakeSlice() return _FakeSlice()
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()): with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()):
parsed = clean_decode(payload) parsed = clean_decode(payload)
assert parsed == "Telegram Premium Ref#abc" assert parsed == "Telegram Premium Ref#abc"
@@ -126,7 +126,7 @@ def test_clean_decode_returns_cell_for_binary_payload() -> None:
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=") payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
fake_cell: object = _FakeCell() fake_cell: object = _FakeCell()
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell): with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell):
parsed = clean_decode(payload) parsed = clean_decode(payload)
assert parsed is fake_cell assert parsed is fake_cell
+6 -32
View File
@@ -5,7 +5,6 @@ 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
@@ -16,30 +15,6 @@ 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
@@ -68,11 +43,6 @@ 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)
@@ -85,7 +55,7 @@ def test_invalid_mnemonic_length_raises() -> None:
def test_valid_mnemonic_lengths() -> None: def test_valid_mnemonic_lengths() -> None:
for length in sorted(MNEMONIC_WORD_COUNTS_VALID): for length in (12, 18, 24):
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
@@ -99,6 +69,11 @@ 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
@@ -134,7 +109,6 @@ 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
+22 -24
View File
@@ -1,4 +1,4 @@
"""Exercise transaction signing, balance checks, and retry behavior for GRAM (ex TON) and USDT flows.""" """Exercise transaction signing, balance checks, and retry behavior for TON and USDT flows."""
from collections.abc import Generator from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
@@ -8,8 +8,7 @@ import pytest
from tonutils.exceptions import ProviderResponseError from tonutils.exceptions import ProviderResponseError
from pyfragment import TransactionError, WalletError from pyfragment import TransactionError, WalletError
from pyfragment.enums import PaymentMethod from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.services.tonapi.transaction import process_transaction
from tests.shared import VALID_SEED from tests.shared import VALID_SEED
@@ -22,7 +21,7 @@ TRANSACTION_DATA = {
"messages": [ "messages": [
{ {
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a", "address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
"amount": "500000000", # 0.5 GRAM "amount": "500000000", # 0.5 TON
"payload": "", "payload": "",
} }
] ]
@@ -48,13 +47,12 @@ 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.services.tonapi.transaction._make_ton_client", return_value=mock_ton_ctx), patch("pyfragment.domains.tonapi.transaction.TonapiClient") as mock_tonapi,
patch("pyfragment.services.tonapi.transaction.WALLET_CLASSES") as mock_classes, patch("pyfragment.domains.tonapi.transaction.WALLET_CLASSES") as mock_classes,
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__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
@@ -64,8 +62,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 GRAM, above threshold wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
wallet.transfer.assert_called_once() wallet.transfer.assert_called_once()
@@ -73,7 +71,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 GRAM, below threshold wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, 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)
@@ -83,14 +81,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.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
@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 nanogram below transaction amount threshold wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanoton 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)
@@ -125,7 +123,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
async def test_rate_limit_retries_and_succeeds() -> None: async def test_rate_limit_retries_and_succeeds() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")]) wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""): with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
assert wallet.transfer.call_count == 2 assert wallet.transfer.call_count == 2
@@ -136,18 +134,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.services.tonapi.transaction.clean_decode", return_value=""): with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
with pytest.raises(TransactionError, match="seqno"): with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA) await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3 assert wallet.transfer.call_count == 3
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_usdt_payment_requires_min_gram_gas_reserve() -> None: async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 GRAM below MIN_GRAM_BALANCE wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)): with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient GRAM"): with pytest.raises(WalletError, match="Insufficient TON balance"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method=PaymentMethod.USDT_GRAM) await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -168,13 +166,13 @@ async def test_usdt_payment_checks_usdt_balance() -> None:
with ( with (
_patch_wallet(wallet), _patch_wallet(wallet),
patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""),
patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)),
): ):
with pytest.raises(WalletError, match="Insufficient USDT balance"): with pytest.raises(WalletError, match="Insufficient USDT balance"):
await process_transaction( await process_transaction(
_make_client(), _make_client(),
transaction, transaction,
payment_method=PaymentMethod.USDT_GRAM, payment_method="usdt_ton",
required_payment_amount=12.5, required_payment_amount=12.5,
) )
+12 -129
View File
@@ -1,21 +1,13 @@
"""Cover stars purchase and giveaway flows, including validation and request wiring.""" """Cover stars purchase and giveaway flows, including validation and request wiring."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod _purchase_stars_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
import pyfragment.domains.purchases.purchase as _purchase_stars_mod _giveaway_stars_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
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
@@ -24,13 +16,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=STARS_PURCHASE_MIN - 1) await client.purchase_stars("@user", amount=49)
@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=STARS_PURCHASE_MAX + 1) await client.purchase_stars("@user", amount=1_000_001)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -87,7 +79,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=PaymentMethod.USDT_GRAM) await client.purchase_stars("@user", amount=500, payment_method="usdt_ton")
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"
@@ -122,25 +114,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=STARS_WINNERS_MIN - 1, amount=STARS_GIVEAWAY_MIN) await client.giveaway_stars("@channel", winners=0, amount=500)
@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=STARS_WINNERS_MAX + 1, amount=STARS_GIVEAWAY_MIN) await client.giveaway_stars("@channel", winners=6, amount=500)
@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=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MIN - 1) await client.giveaway_stars("@channel", winners=1, amount=499)
@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=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MAX + 1) await client.giveaway_stars("@channel", winners=1, amount=1_000_001)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -173,8 +165,6 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
AsyncMock( AsyncMock(
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,
] ]
@@ -197,8 +187,6 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N
call_mock = AsyncMock( call_mock = AsyncMock(
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,
] ]
@@ -209,9 +197,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=PaymentMethod.USDT_GRAM) await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton")
init_call = call_mock.await_args_list[3] init_call = call_mock.await_args_list[1]
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
@@ -236,108 +224,3 @@ 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)
+9 -139
View File
@@ -1,15 +1,13 @@
"""Cover premium purchase and giveaway flows, including validation and request wiring.""" """Cover premium purchase and giveaway flows, including validation and request wiring."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod _purchase_premium_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
import pyfragment.domains.purchases.purchase as _purchase_premium_mod _giveaway_premium_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
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
@@ -24,7 +22,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=min(PREMIUM_MONTHS_VALID) - 1) await client.purchase_premium("@user", months=0)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -78,7 +76,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=PaymentMethod.USDT_GRAM) await client.purchase_premium("@user", months=6, payment_method="usdt_ton")
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"
@@ -87,25 +85,6 @@ 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:
@@ -132,13 +111,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=PREMIUM_WINNERS_MIN - 1, months=3) await client.giveaway_premium("@channel", winners=0, 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=PREMIUM_WINNERS_MAX + 1, months=3) await client.giveaway_premium("@channel", winners=24_001, months=3)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -171,8 +150,6 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
AsyncMock( AsyncMock(
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,
] ]
@@ -195,8 +172,6 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) ->
call_mock = AsyncMock( call_mock = AsyncMock(
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,
] ]
@@ -207,9 +182,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=PaymentMethod.USDT_GRAM) await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton")
init_call = call_mock.await_args_list[3] init_call = call_mock.await_args_list[1]
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
@@ -234,108 +209,3 @@ 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)
+17 -65
View File
@@ -1,40 +1,40 @@
"""Cover GRAM (ex TON) top-up through Telegram Ads, including recipient lookup and transaction building.""" """Cover TON top-up through Telegram Ads, including recipient lookup and transaction building."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
import pyfragment.domains.ads.tonup as _topup_gram_mod _topup_ton_mod = importlib.import_module("pyfragment.domains.ads.tonup")
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 GRAM (ex TON) validation tests # Topup TON validation tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_topup_gram_amount_zero(client: FragmentClient) -> None: async def test_topup_ton_amount_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.topup_gram("@user", amount=GRAM_TOPUP_MIN - 1) await client.topup_ton("@user", amount=0)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_topup_gram_amount_too_high(client: FragmentClient) -> None: async def test_topup_ton_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.topup_gram("@user", amount=GRAM_TOPUP_MAX + 1) await client.topup_ton("@user", amount=1_000_000_001)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_topup_gram_float_amount(client: FragmentClient) -> None: async def test_topup_ton_float_amount(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.topup_gram("@user", amount=1.5) # type: ignore[arg-type] await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type]
# Topup GRAM (ex TON) mocked tests # Topup TON mocked tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_topup_gram_success(client: FragmentClient) -> None: async def test_topup_ton_success(client: FragmentClient) -> None:
with ( with (
patch.object( patch.object(
client, client,
@@ -48,10 +48,10 @@ async def test_topup_gram_success(client: FragmentClient) -> None:
] ]
), ),
), ),
patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_topup_gram_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
): ):
result = await client.topup_gram("@user", amount=10) result = await client.topup_ton("@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
@@ -60,7 +60,7 @@ async def test_topup_gram_success(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_topup_gram_user_not_found(client: FragmentClient) -> None: async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
with patch.object( with patch.object(
client, client,
"call", "call",
@@ -72,52 +72,4 @@ async def test_topup_gram_user_not_found(client: FragmentClient) -> None:
), ),
): ):
with pytest.raises(UserNotFoundError): with pytest.raises(UserNotFoundError):
await client.topup_gram("@ghost", amount=10) await client.topup_ton("@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)
+14 -108
View File
@@ -1,30 +1,30 @@
"""Verify wallet inspection returns friendly GRAM (ex TON) and USDT balances from Tonapi.""" """Verify wallet inspection returns friendly TON and USDT balances from Tonapi."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from pyfragment import FragmentClient, WalletInfo from pyfragment import FragmentClient, WalletInfo
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOGRAM from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
# Wallet mocked tests (GRAM and USDT balances are returned separately) # Wallet mocked tests (TON 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_NANOGRAM mock_wallet.balance = FAKE_BALANCE_NANOTON
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.services.tonapi.account._make_ton_client") as mock_tonapi, patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi,
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes, patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=12.3456)), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=12.3456)),
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
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.gram_balance == round(FAKE_BALANCE_NANOGRAM / 1_000_000_000, 4) assert result.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
assert result.usdt_balance == 12.3456 assert result.usdt_balance == 12.3456
@@ -45,110 +45,16 @@ 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.services.tonapi.account._make_ton_client") as mock_tonapi, patch("pyfragment.domains.tonapi.account.TonapiClient") as mock_tonapi,
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes, patch("pyfragment.domains.tonapi.account.WALLET_CLASSES") as mock_classes,
patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=0.0)), patch("pyfragment.domains.tonapi.account.get_usdt_balance", AsyncMock(return_value=0.0)),
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
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.gram_balance == 0.0 assert result.ton_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)
+11 -11
View File
@@ -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
@@ -15,8 +15,8 @@ from tests.shared import FAKE_HASH, FAKE_RESPONSE
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_returns_api_response(client: FragmentClient) -> None: async def test_call_returns_api_response(client: FragmentClient) -> None:
with ( with (
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
): ):
result = await client.call("anyMethod", {"key": "value"}) result = await client.call("anyMethod", {"key": "value"})
@@ -26,8 +26,8 @@ async def test_call_returns_api_response(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_default_page_url(client: FragmentClient) -> None: async def test_call_default_page_url(client: FragmentClient) -> None:
with ( with (
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
): ):
result = await client.call("anyMethod") result = await client.call("anyMethod")
@@ -39,8 +39,8 @@ async def test_call_no_data(client: FragmentClient) -> None:
mock_request = AsyncMock(return_value={}) mock_request = AsyncMock(return_value={})
with ( with (
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.fragment_request", mock_request), patch("pyfragment.client.fragment_request", mock_request),
): ):
await client.call("anyMethod") await client.call("anyMethod")
@@ -53,8 +53,8 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
mock_request = AsyncMock(return_value={}) mock_request = AsyncMock(return_value={})
with ( with (
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.fragment_request", mock_request), patch("pyfragment.client.fragment_request", mock_request),
): ):
await client.call("anyMethod", {"key": "value", "num": 7}) await client.call("anyMethod", {"key": "value", "num": 7})
@@ -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=Response) response = MagicMock(spec=httpx.Response)
response.status_code = 429 response.status_code = 429
session = AsyncMock(spec=AsyncSession) session = AsyncMock(spec=httpx.AsyncClient)
session.post = AsyncMock(return_value=response) session.post = AsyncMock(return_value=response)
with pytest.raises(FragmentPageError, match="429"): with pytest.raises(FragmentPageError, match="429"):
+4 -50
View File
@@ -1,12 +1,12 @@
"""Cover Telegram Ads recharge flow, including request preparation and KYC handling.""" """Cover Telegram Ads recharge flow, including request preparation and KYC handling."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
import pyfragment.domains.ads.recharge as _recharge_ads_mod _recharge_ads_mod = importlib.import_module("pyfragment.domains.ads.recharge")
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
@@ -15,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=GRAM_TOPUP_MIN - 1) await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0)
@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=GRAM_TOPUP_MAX + 1) await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -55,49 +55,3 @@ async def test_recharge_ads_success(client: FragmentClient) -> None:
assert isinstance(result, AdsRechargeResult) assert isinstance(result, AdsRechargeResult)
assert result.transaction_id == FAKE_TX_HASH assert result.transaction_id == FAKE_TX_HASH
assert result.amount == 10 assert result.amount == 10
# recharge_ads — error branches
@pytest.mark.asyncio
async def test_recharge_ads_missing_req_id_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import FragmentAPIError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsState
{"amount": "0.1"}, # initAdsRechargeRequest — no req_id
]
),
),
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(FragmentAPIError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
@pytest.mark.asyncio
async def test_recharge_ads_need_verify_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import VerificationError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsState
{"req_id": FAKE_REQ_ID},
{"need_verify": True}, # getAdsRechargeLink
]
),
),
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(VerificationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
+1 -1
View File
@@ -22,7 +22,7 @@ def _mock_rookiepy(jar: list[dict[str, str]] | None = None) -> MagicMock:
return mock return mock
PATCH = "pyfragment.services.cookies.service.rookiepy" PATCH = "pyfragment.core.cookies.rookiepy"
# unsupported browser tests # unsupported browser tests
+2 -2
View File
@@ -10,8 +10,8 @@ import pyfragment.domains.ads.recharge # noqa: F401
import pyfragment.domains.ads.tonup # noqa: F401 import pyfragment.domains.ads.tonup # noqa: F401
import pyfragment.domains.giveaways.giveaway # noqa: F401 import pyfragment.domains.giveaways.giveaway # noqa: F401
import pyfragment.domains.purchases.purchase # noqa: F401 import pyfragment.domains.purchases.purchase # noqa: F401
import pyfragment.services.tonapi.account # noqa: F401 import pyfragment.domains.tonapi.account # noqa: F401
import pyfragment.services.tonapi.transaction # noqa: F401 import pyfragment.domains.tonapi.transaction # noqa: F401
from pyfragment import FragmentClient from pyfragment import FragmentClient
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
+6 -2
View File
@@ -1,7 +1,7 @@
"""Shared test constants for the pyfragment test suite. """Shared test constants for the pyfragment test suite.
pyfragment is an async Python client for the Fragment API a unified toolkit pyfragment is an async Python client for the Fragment API a unified toolkit
to manage Telegram assets: purchase Stars and Premium, top up GRAM (ex TON) and Ads balances, to manage Telegram assets: purchase Stars and Premium, top up 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,11 +31,15 @@ FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}}
# get_wallet() # get_wallet()
FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5" FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5"
FAKE_BALANCE_NANOGRAM: int = 1_500_000_000 # 1.5 GRAM (ex TON) FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 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>