mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-26 14:51:40 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f8197b710 | |||
| e1478ec8b0 | |||
| 35ab0e337d | |||
| eec59f7e12 | |||
| 1f6085acdc | |||
| 1a070ce8b9 |
@@ -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]"
|
||||||
|
|||||||
@@ -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
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
[](https://pypi.org/project/pyfragment/)
|
<p style="font-size: 18px; margin-bottom: 24px;">
|
||||||
[](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>
|
||||||
[](https://python.org)
|
</p>
|
||||||
[](https://github.com/bohd4nx/pyfragment/actions)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
Async Python client for the **[Fragment.com](https://fragment.com)** marketplace API.
|
[](https://pypi.org/project/pyfragment/)
|
||||||
|
[](https://pepy.tech/projects/pyfragment)
|
||||||
|
[](https://python.org)
|
||||||
|
[](https://github.com/bohd4nx/pyfragment/actions)
|
||||||
|
[](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
@@ -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.
|
|
||||||
@@ -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):")
|
||||||
|
|||||||
@@ -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):")
|
||||||
|
|||||||
@@ -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):")
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 # 1–1 000 000 000 GRAM (ex TON)
|
AMOUNT = 10 # 1–1 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 # 1–24 000
|
WINNERS = 10 # 1–24 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 # 1–15
|
WINNERS = 3 # 1–5
|
||||||
AMOUNT = 1000 # 500–1 000 000 stars per winner
|
AMOUNT = 1000 # 500–1 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 # 50–10 000 000 stars
|
AMOUNT = 500 # 50–1 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 # 1–1 000 000 000 GRAM (ex TON)
|
AMOUNT = 10 # 1–1 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 +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
@@ -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
@@ -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 {})})
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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}')"
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pyfragment.domains.base import BaseService
|
from pyfragment.domains.base import BaseService
|
||||||
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
|
|
||||||
from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
|
from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
|
||||||
|
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
|
|
||||||
from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars
|
from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars
|
||||||
from pyfragment.domains.purchases.service import PurchasesService
|
from pyfragment.domains.purchases.service import PurchasesService
|
||||||
|
from pyfragment.models.payments import PremiumResult, StarsResult
|
||||||
|
|
||||||
__all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"]
|
__all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
+3
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from pyfragment.services.cookies import CookieResult, get_cookies_from_browser
|
|
||||||
|
|
||||||
__all__ = ["CookieResult", "get_cookies_from_browser"]
|
|
||||||
@@ -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"]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from pyfragment.services.tonapi.service import TonapiService
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"TonapiService",
|
|
||||||
]
|
|
||||||
@@ -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)"
|
|
||||||
)
|
|
||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"):
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user