mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
@@ -10,8 +10,6 @@ jobs:
|
||||
lint:
|
||||
name: Lint & Format
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -19,21 +17,20 @@ jobs:
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: pip
|
||||
|
||||
- run: pip install ".[dev]"
|
||||
|
||||
- run: ruff check . && ruff format --check . && mypy pyfragment
|
||||
- run: ruff check . && ruff format --check . && mypy pyfragment --explicit-package-bases
|
||||
|
||||
test:
|
||||
name: Tests (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet
|
||||
python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -41,6 +38,7 @@ jobs:
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
|
||||
- name: Install package and dev dependencies
|
||||
run: pip install ".[dev]"
|
||||
|
||||
@@ -11,8 +11,6 @@ jobs:
|
||||
name: Version Check
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.value }}
|
||||
is-new: ${{ steps.tag.outputs.is-new }}
|
||||
@@ -42,8 +40,6 @@ jobs:
|
||||
needs: version-check
|
||||
if: needs.version-check.outputs.is-new == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -65,8 +61,6 @@ jobs:
|
||||
name: Publish to PyPI
|
||||
needs: [version-check, build]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/pyfragment/
|
||||
@@ -85,33 +79,19 @@ jobs:
|
||||
name: GitHub Release
|
||||
needs: [version-check, build]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- name: Extract latest changelog entry
|
||||
id: changelog
|
||||
run: |
|
||||
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$body" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: v${{ needs.version-check.outputs.version }}
|
||||
name: v${{ needs.version-check.outputs.version }}
|
||||
files: dist/*
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
generate_release_notes: true
|
||||
make_latest: true
|
||||
|
||||
@@ -7,6 +7,34 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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)
|
||||
core/ — transport, cookies, constants
|
||||
domains/ — one package per feature domain
|
||||
ads/ — recharge_ads, topup_ton
|
||||
anonymous_numbers/— buy_number, manage_number
|
||||
giveaways/ — giveaway_stars, giveaway_premium
|
||||
marketplace/ — search_usernames, search_numbers, search_gifts
|
||||
purchases/ — purchase_stars, purchase_premium
|
||||
tonapi/ — wallet info, transaction signing
|
||||
models/ — result dataclasses and enums
|
||||
exceptions.py — exception hierarchy
|
||||
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 httpx clients.
|
||||
- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.domains.tonapi.transaction.process_transaction`.
|
||||
- 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,23 +1,23 @@
|
||||
<div align="center">
|
||||
<img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="Fragment Logo" width="120" height="120" style="border-radius: 24px;">
|
||||
<img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="pyfragment" width="96" height="96" style="border-radius: 20px;"><br><br>
|
||||
|
||||
<h1 style="margin-top: 24px;">Fragment API</h1>
|
||||
# pyfragment
|
||||
|
||||
<p style="font-size: 18px; margin-bottom: 24px;">
|
||||
<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>
|
||||
</p>
|
||||
[](https://pypi.org/project/pyfragment/)
|
||||
[](https://pepy.tech/projects/pyfragment)
|
||||
[](https://python.org)
|
||||
[](https://github.com/bohd4nx/pyfragment/actions)
|
||||
[](LICENSE)
|
||||
|
||||
[](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)
|
||||
Async Python client for the **[Fragment](https://fragment.com)** marketplace API.
|
||||
Buy Stars & Premium, run giveaways, top up TON and Ads balances,
|
||||
manage anonymous numbers, and search Fragment listings.
|
||||
|
||||
[Documentation](https://bohd4nx.gitbook.io/pyfragment/) · [Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
|
||||
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate TON](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)**
|
||||
|
||||
</div>
|
||||
|
||||
> **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).
|
||||
> **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
|
||||
|
||||
---
|
||||
|
||||
@@ -27,57 +27,14 @@
|
||||
pip install pyfragment
|
||||
```
|
||||
|
||||
To install the latest unreleased changes from the `dev` branch:
|
||||
|
||||
```bash
|
||||
# Latest dev build
|
||||
pip install git+https://github.com/bohd4nx/pyfragment.git@dev
|
||||
```
|
||||
|
||||
Requires Python 3.10+.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
## Quick Start
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
@@ -96,34 +53,108 @@ async def main() -> None:
|
||||
},
|
||||
) as client:
|
||||
wallet = await client.get_wallet()
|
||||
print(f"Wallet: {wallet.address} | TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
|
||||
print(f"TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
|
||||
|
||||
recipient = "https://t.me/username" # also supports: @username, username
|
||||
recipient = "https://t.me/username" # also: @username, username
|
||||
|
||||
stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton")
|
||||
print(f"Stars sent: {stars.amount} to {stars.username} | tx: {stars.transaction_id}")
|
||||
print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}")
|
||||
|
||||
premium = await client.purchase_premium(recipient, months=6, payment_method="ton")
|
||||
print(f"Premium sent: {premium.amount} months to {premium.username} | tx: {premium.transaction_id}")
|
||||
print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Full runnable examples:
|
||||
---
|
||||
|
||||
- https://github.com/bohd4nx/pyfragment/tree/master/examples
|
||||
## Configuration
|
||||
|
||||
Payload debug/decode helper (thanks):
|
||||
| Parameter | Type | Default | Description |
|
||||
| ---------------- | ------------- | -------- | ----------------------------------------------------------- |
|
||||
| `seed` | `str` | — | 12- or 24-word TON wallet mnemonic |
|
||||
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
|
||||
| `cookies` | `dict \| str` | — | Fragment session cookies |
|
||||
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal |
|
||||
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
|
||||
|
||||
- https://ton-cell-abi-viewer.vercel.app/
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
### Fragment cookies
|
||||
|
||||
Log in to [fragment.com](https://fragment.com) and connect your TON wallet.
|
||||
|
||||
**Automatically** (recommended) — reads directly from your browser, no extension needed:
|
||||
|
||||
```bash
|
||||
pip install "pyfragment[browser]"
|
||||
```
|
||||
|
||||
```python
|
||||
from pyfragment import get_cookies_from_browser
|
||||
|
||||
result = get_cookies_from_browser("chrome") # firefox, edge, brave, ...
|
||||
# result.cookies — dict[str, str] ready to pass to FragmentClient
|
||||
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
|
||||
```
|
||||
|
||||
**Manually** — use [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass as a `dict` or JSON string.
|
||||
|
||||
Refresh cookies when you get authentication errors.
|
||||
|
||||
### Tonapi key
|
||||
|
||||
Generate at [tonconsole.com](https://tonconsole.com).
|
||||
|
||||
### Seed phrase
|
||||
|
||||
12- or 24-word mnemonic from your TON wallet (**Tonkeeper → Settings → Backup**). Never share it.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
`pyfragment` uses the standard `logging` module under the `pyfragment` namespace and is silent by default:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("pyfragment").setLevel(logging.DEBUG) # for detailed request logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
All exceptions inherit from `FragmentError`:
|
||||
|
||||
```python
|
||||
from pyfragment import (
|
||||
ConfigurationError, # invalid arguments (amount, months, payment_method…)
|
||||
UserNotFoundError, # recipient not found on Fragment
|
||||
WalletError, # insufficient TON or USDT balance
|
||||
TransactionError, # broadcast failed, duplicate seqno, invalid payload
|
||||
FragmentAPIError, # Fragment API returned an error response
|
||||
FragmentPageError, # page fetch or hash extraction failed
|
||||
AnonymousNumberError, # number not owned, wrong state, login code issues
|
||||
CookieError, # missing or malformed session cookies
|
||||
ParseError, # failed to decode Fragment payload
|
||||
VerificationError, # on-chain verification step failed
|
||||
OperationError, # generic operation-level failure
|
||||
UnexpectedError, # unexpected API response structure
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
### Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx)
|
||||
Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
|
||||
|
||||
**Star ⭐ this repo if you found it useful!**
|
||||
**Star ⭐ if you found it useful**
|
||||
|
||||
</div>
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# 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 (TON seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity.
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from importlib.metadata import version
|
||||
|
||||
from pyfragment.client import FragmentClient
|
||||
@@ -20,12 +21,14 @@ from pyfragment.exceptions import (
|
||||
)
|
||||
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
|
||||
from pyfragment.models.cookies import CookieResult
|
||||
from pyfragment.models.enums import PaymentMethod
|
||||
from pyfragment.models.enums import PaymentMethod, WalletVersion
|
||||
from pyfragment.models.giveaways import PremiumGiveawayResult, StarsGiveawayResult
|
||||
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
|
||||
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
|
||||
from pyfragment.models.wallet import WalletInfo
|
||||
|
||||
logging.getLogger("pyfragment").addHandler(logging.NullHandler())
|
||||
|
||||
__version__: str = version("pyfragment")
|
||||
|
||||
__all__ = [
|
||||
@@ -62,5 +65,6 @@ __all__ = [
|
||||
"UnexpectedError",
|
||||
# literal types
|
||||
"PaymentMethod",
|
||||
"WalletVersion",
|
||||
"get_cookies_from_browser",
|
||||
]
|
||||
|
||||
+36
-26
@@ -3,12 +3,10 @@ from __future__ import annotations
|
||||
import json
|
||||
from typing import Any, cast, get_args
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
|
||||
from pyfragment.core.transport import fragment_request, get_fragment_hash
|
||||
from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
|
||||
from pyfragment.domains.ads.service import AdsService
|
||||
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
|
||||
from pyfragment.domains.base import raw_api_call
|
||||
from pyfragment.domains.giveaways.service import GiveawaysService
|
||||
from pyfragment.domains.marketplace.service import MarketplaceService
|
||||
from pyfragment.domains.purchases.service import PurchasesService
|
||||
@@ -31,7 +29,7 @@ class FragmentClient:
|
||||
connected with Fragment or Telegram.
|
||||
|
||||
Args:
|
||||
seed: 24-word mnemonic phrase for the TON wallet.
|
||||
seed: 12- or 24-word mnemonic phrase for the TON wallet.
|
||||
api_key: Tonapi API key — get one at https://tonconsole.com.
|
||||
cookies: Fragment session cookies as a dict or JSON string.
|
||||
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
|
||||
@@ -53,14 +51,17 @@ class FragmentClient:
|
||||
print(result.transaction_id)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
seed: str,
|
||||
api_key: str,
|
||||
cookies: dict[str, Any] | str,
|
||||
wallet_version: str = "V5R1",
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> None:
|
||||
@staticmethod
|
||||
def _parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]:
|
||||
if isinstance(cookies, str):
|
||||
try:
|
||||
cookies = json.loads(cookies)
|
||||
except Exception as exc:
|
||||
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
|
||||
return cast(dict[str, Any], cookies)
|
||||
|
||||
@staticmethod
|
||||
def _validate_required(seed: str, api_key: str) -> None:
|
||||
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
|
||||
if missing:
|
||||
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
|
||||
@@ -72,16 +73,14 @@ class FragmentClient:
|
||||
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()]
|
||||
@staticmethod
|
||||
def _validate_cookie_keys(cookies: dict[str, Any]) -> None:
|
||||
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
|
||||
if missing_keys:
|
||||
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_wallet_version(wallet_version: str) -> WalletVersion:
|
||||
version = wallet_version.strip().upper()
|
||||
if version not in get_args(WalletVersion):
|
||||
raise ConfigurationError(
|
||||
@@ -89,11 +88,25 @@ class FragmentClient:
|
||||
version=version, supported=", ".join(sorted(get_args(WalletVersion)))
|
||||
)
|
||||
)
|
||||
return cast(WalletVersion, version)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
seed: str,
|
||||
api_key: str,
|
||||
cookies: dict[str, Any] | str,
|
||||
wallet_version: str = "V5R1",
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> None:
|
||||
self._validate_required(seed, api_key)
|
||||
parsed_cookies = self._parse_cookies(cookies)
|
||||
self._validate_cookie_keys(parsed_cookies)
|
||||
version = self._normalize_wallet_version(wallet_version)
|
||||
|
||||
self.seed: str = seed.strip()
|
||||
self.api_key: str = api_key.strip()
|
||||
self.cookies: dict[str, Any] = cast(dict[str, Any], cookies)
|
||||
self.wallet_version: WalletVersion = version # type: ignore[assignment]
|
||||
self.cookies: dict[str, Any] = parsed_cookies
|
||||
self.wallet_version: WalletVersion = version
|
||||
self.timeout: float = timeout
|
||||
self.marketplace = MarketplaceService(self)
|
||||
self.purchases = PurchasesService(self)
|
||||
@@ -371,7 +384,4 @@ class FragmentClient:
|
||||
page_url="https://fragment.com/premium/gift",
|
||||
)
|
||||
"""
|
||||
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 {})})
|
||||
return await raw_api_call(self.cookies, self.timeout, method, data, page_url)
|
||||
|
||||
@@ -9,9 +9,9 @@ from pyfragment.exceptions import CookieError
|
||||
from pyfragment.models.cookies import CookieResult
|
||||
|
||||
try:
|
||||
import rookiepy # type: ignore[import-not-found]
|
||||
except Exception:
|
||||
rookiepy = None
|
||||
import rookiepy
|
||||
except Exception: # noqa: BLE001
|
||||
rookiepy = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
|
||||
@@ -13,6 +14,9 @@ if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
|
||||
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
||||
@@ -42,7 +46,9 @@ async def recharge_ads(client: FragmentClient, account: str, amount: int) -> Ads
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
logger.error("Failed to recharge Ads account '%s' for %s TON: %s", account, amount, exc, exc_info=True)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to recharge Ads account '%s' for %s TON due to an unexpected error", account, amount)
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
|
||||
@@ -21,6 +22,9 @@ if TYPE_CHECKING:
|
||||
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)
|
||||
@@ -57,7 +61,9 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
|
||||
tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount)
|
||||
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
logger.error("Failed to top up TON for user '%s' with %s TON: %s", username, amount, exc, exc_info=True)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to top up TON for user '%s' with %s TON due to an unexpected error", username, amount)
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.core.constants import NUMBERS_PAGE
|
||||
@@ -12,6 +13,9 @@ if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _strip_plus(number: str) -> str:
|
||||
return number.lstrip("+") if isinstance(number, str) else number
|
||||
|
||||
@@ -32,9 +36,11 @@ async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult
|
||||
|
||||
return LoginCodeResult(number=number, code=code, active_sessions=active_sessions)
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
logger.error("Failed to get login code for number '%s': %s", number, exc, exc_info=True)
|
||||
raise
|
||||
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
|
||||
|
||||
|
||||
@@ -50,9 +56,21 @@ async def toggle_login_codes(client: FragmentClient, number: str, can_receive: b
|
||||
if result.get("error"):
|
||||
raise FragmentAPIError(html.unescape(result["error"]))
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
logger.error(
|
||||
"Failed to toggle login code delivery for number '%s' (can_receive=%s): %s",
|
||||
number,
|
||||
can_receive,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
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
|
||||
|
||||
|
||||
@@ -88,7 +106,9 @@ async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSe
|
||||
|
||||
return TerminateSessionsResult(number=number, message=result.get("msg"))
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
logger.error("Failed to terminate sessions for number '%s': %s", number, exc, exc_info=True)
|
||||
raise
|
||||
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
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.core.constants import BASE_HEADERS
|
||||
from pyfragment.core.transport import fragment_request, get_fragment_hash
|
||||
|
||||
if TYPE_CHECKING:
|
||||
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,
|
||||
) -> dict[str, Any]:
|
||||
payload = {"method": method, **(data or {})}
|
||||
headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
|
||||
logger.debug("Starting Fragment API call '%s' on %s", method, page_url)
|
||||
try:
|
||||
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
|
||||
fragment_hash = await get_fragment_hash(cookies, headers, page_url, timeout)
|
||||
response = await fragment_request(session, fragment_hash, 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:
|
||||
def __init__(self, client: FragmentClient) -> None:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from typing import TYPE_CHECKING, get_args
|
||||
|
||||
from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
|
||||
@@ -22,6 +24,9 @@ if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def giveaway_stars(
|
||||
client: FragmentClient,
|
||||
channel: str,
|
||||
@@ -47,6 +52,12 @@ async def giveaway_stars(
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||
|
||||
await client.call(
|
||||
"updateStarsGiveawayState",
|
||||
{"mode": "new", "lv": "false", "dh": str(random.randint(100_000_000, 999_999_999))},
|
||||
page_url=STARS_GIVEAWAY_PAGE,
|
||||
)
|
||||
|
||||
result = await client.call(
|
||||
"initGiveawayStarsRequest",
|
||||
{
|
||||
@@ -84,9 +95,25 @@ async def giveaway_stars(
|
||||
)
|
||||
return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount)
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -119,6 +146,17 @@ async def giveaway_premium(
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||
|
||||
await client.call(
|
||||
"updatePremiumGiveawayState",
|
||||
{
|
||||
"mode": "new",
|
||||
"lv": "false",
|
||||
"dh": str(random.randint(100_000_000, 999_999_999)),
|
||||
"quantity": "",
|
||||
},
|
||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||
)
|
||||
|
||||
result = await client.call(
|
||||
"initGiveawayPremiumRequest",
|
||||
{
|
||||
@@ -156,7 +194,23 @@ async def giveaway_premium(
|
||||
)
|
||||
return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months)
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyfragment.core.constants import FRAGMENT_BASE_URL, GIFTS_PAGE, NUMBERS_PAGE
|
||||
@@ -11,6 +12,9 @@ if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def search_usernames(
|
||||
client: FragmentClient,
|
||||
query: str = "",
|
||||
@@ -36,9 +40,19 @@ async def search_usernames(
|
||||
next_offset_id = str(raw_noi) if raw_noi else None
|
||||
return UsernamesResult(items=items, next_offset_id=next_offset_id)
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -67,9 +81,19 @@ async def search_numbers(
|
||||
next_offset_id = str(raw_noi) if raw_noi else None
|
||||
return NumbersResult(items=items, next_offset_id=next_offset_id)
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -106,7 +130,19 @@ async def search_gifts(
|
||||
items, next_offset = parse_gift_items(result.get("html") or "")
|
||||
return GiftsResult(items=items, next_offset=next_offset)
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, get_args
|
||||
|
||||
@@ -23,6 +24,9 @@ if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def purchase_stars(
|
||||
client: FragmentClient,
|
||||
username: str,
|
||||
@@ -84,9 +88,23 @@ async def purchase_stars(
|
||||
)
|
||||
return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
logger.error(
|
||||
"Failed to purchase %s Stars for user '%s' using '%s': %s",
|
||||
amount,
|
||||
username,
|
||||
payment_method,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
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
|
||||
|
||||
|
||||
@@ -151,7 +169,21 @@ async def purchase_premium(
|
||||
)
|
||||
return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
|
||||
|
||||
except FragmentError:
|
||||
except FragmentError as exc:
|
||||
logger.error(
|
||||
"Failed to purchase %s months of Premium for user '%s' using '%s': %s",
|
||||
months,
|
||||
username,
|
||||
payment_method,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
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
|
||||
|
||||
@@ -1,23 +1,5 @@
|
||||
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,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ton_core import NetworkGlobalID
|
||||
@@ -16,6 +17,9 @@ if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
|
||||
"""Return the USDT balance for a Fragment-linked TON wallet."""
|
||||
try:
|
||||
@@ -29,9 +33,12 @@ async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
|
||||
return float(raw_balance) / 1_000_000.0
|
||||
except ProviderResponseError as exc:
|
||||
if exc.code == 404:
|
||||
logger.debug("No USDT jetton wallet found for '%s'; treating balance as 0", wallet_address)
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -47,6 +54,11 @@ async def check_ton_payment_balance(
|
||||
|
||||
required_ton = max(tx_price_ton, MIN_TON_BALANCE)
|
||||
if balance_ton < required_ton:
|
||||
logger.error(
|
||||
"Failed TON balance check: balance=%s TON, required=%s TON",
|
||||
round(balance_ton, 6),
|
||||
round(required_ton, 6),
|
||||
)
|
||||
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton))
|
||||
|
||||
|
||||
@@ -58,11 +70,22 @@ async def check_usdt_payment_balance(
|
||||
) -> None:
|
||||
"""Validate that the wallet can cover a USDT-denominated payment."""
|
||||
if balance_ton < MIN_TON_BALANCE:
|
||||
logger.error(
|
||||
"Failed TON gas reserve check for USDT payment: balance=%s TON, required=%s TON",
|
||||
round(balance_ton, 6),
|
||||
MIN_TON_BALANCE,
|
||||
)
|
||||
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_BALANCE))
|
||||
|
||||
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
|
||||
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))
|
||||
|
||||
|
||||
@@ -80,6 +103,7 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]:
|
||||
"walletStateInit": base64.b64encode(boc).decode(),
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
@@ -99,4 +123,5 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo:
|
||||
usdt_balance=round(usdt_balance, 4),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to fetch wallet info from Tonapi")
|
||||
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import random
|
||||
import ssl
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -19,6 +20,9 @@ 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.
|
||||
|
||||
@@ -43,9 +47,81 @@ def clean_decode(payload: str) -> str | Cell:
|
||||
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_ton: 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_ton = wallet.balance / 1_000_000_000
|
||||
if payment_method == "ton":
|
||||
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:
|
||||
logger.exception("Failed to validate balances before broadcasting transaction")
|
||||
raise WalletError(WalletError.TON_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"]), # nanotons, not 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],
|
||||
@@ -63,62 +139,32 @@ async def process_transaction(
|
||||
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]
|
||||
message = _extract_message(transaction_data)
|
||||
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
|
||||
await _check_payment_balances(wallet, payment_method, amount_ton, required_payment_amount, transaction_data, ton)
|
||||
|
||||
payload = clean_decode(str(message.get("payload", "")))
|
||||
|
||||
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
|
||||
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 nanotons using payment method '%s'",
|
||||
message["address"],
|
||||
message["amount"],
|
||||
payment_method,
|
||||
)
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
||||
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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
|
||||
@@ -4,7 +4,7 @@ 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
|
||||
from pyfragment.models.wallet import WalletInfo
|
||||
|
||||
__all__ = [
|
||||
"AdsRechargeResult",
|
||||
@@ -19,9 +19,7 @@ __all__ = [
|
||||
"StarsGiveawayResult",
|
||||
"StarsResult",
|
||||
"TerminateSessionsResult",
|
||||
"TonTransferResult",
|
||||
"UsernamesResult",
|
||||
"UsdtTransferResult",
|
||||
"WalletInfo",
|
||||
"WalletVersion",
|
||||
]
|
||||
|
||||
@@ -17,24 +17,4 @@ class WalletInfo:
|
||||
)
|
||||
|
||||
|
||||
@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"]
|
||||
__all__ = ["WalletInfo"]
|
||||
|
||||
+15
-8
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "pyfragment"
|
||||
version = "2026.3.0"
|
||||
version = "2026.3.1"
|
||||
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"
|
||||
license = { text = "MIT" }
|
||||
@@ -21,7 +21,6 @@ keywords = [
|
||||
"telegram-ads",
|
||||
"ton",
|
||||
"ton-blockchain",
|
||||
"tonkeeper",
|
||||
"tonapi",
|
||||
"anonymous-numbers",
|
||||
"username-auctions",
|
||||
@@ -36,7 +35,6 @@ keywords = [
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Financial and Insurance Industry",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Natural Language :: English",
|
||||
"Operating System :: OS Independent",
|
||||
@@ -45,13 +43,13 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Framework :: AsyncIO",
|
||||
"Topic :: Software Development :: Libraries",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Internet",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
"Topic :: Office/Business :: Financial",
|
||||
"Topic :: Office/Business :: Financial :: Investment",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = ["httpx>=0.25", "tonutils>=2.0.1"]
|
||||
@@ -78,7 +76,7 @@ addopts = "-v --tb=short"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 128
|
||||
target-version = "py312"
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
|
||||
@@ -89,9 +87,18 @@ ignore = ["E501", "UP017"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["E402"]
|
||||
"systests/*" = ["E402"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
exclude = ["^systests/", "^examples/"]
|
||||
exclude = ["^examples/"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "rookiepy"
|
||||
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.core.cookies"
|
||||
warn_unused_ignores = false
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Cover stars purchase and giveaway flows, including validation and request wiring."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_purchase_stars_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
|
||||
_giveaway_stars_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
|
||||
import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod
|
||||
import pyfragment.domains.purchases.purchase as _purchase_stars_mod
|
||||
from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
@@ -165,6 +164,7 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
@@ -187,6 +187,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N
|
||||
call_mock = AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
@@ -199,7 +200,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N
|
||||
):
|
||||
await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton")
|
||||
|
||||
init_call = call_mock.await_args_list[1]
|
||||
init_call = call_mock.await_args_list[2]
|
||||
assert init_call.args[0] == "initGiveawayStarsRequest"
|
||||
assert init_call.args[1]["payment_method"] == "usdt_ton"
|
||||
assert proc_mock.await_args is not None
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Cover premium purchase and giveaway flows, including validation and request wiring."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_purchase_premium_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
|
||||
_giveaway_premium_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
|
||||
import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod
|
||||
import pyfragment.domains.purchases.purchase as _purchase_premium_mod
|
||||
from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
@@ -150,6 +149,7 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
@@ -172,6 +172,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) ->
|
||||
call_mock = AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
@@ -184,7 +185,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) ->
|
||||
):
|
||||
await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton")
|
||||
|
||||
init_call = call_mock.await_args_list[1]
|
||||
init_call = call_mock.await_args_list[2]
|
||||
assert init_call.args[0] == "initGiveawayPremiumRequest"
|
||||
assert init_call.args[1]["payment_method"] == "usdt_ton"
|
||||
assert proc_mock.await_args is not None
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Cover TON top-up through Telegram Ads, including recipient lookup and transaction building."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_topup_ton_mod = importlib.import_module("pyfragment.domains.ads.tonup")
|
||||
import pyfragment.domains.ads.tonup as _topup_ton_mod
|
||||
from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ from tests.shared import FAKE_HASH, FAKE_RESPONSE
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_returns_api_response(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
||||
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
||||
):
|
||||
result = await client.call("anyMethod", {"key": "value"})
|
||||
|
||||
@@ -26,8 +26,8 @@ async def test_call_returns_api_response(client: FragmentClient) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_default_page_url(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
||||
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
||||
):
|
||||
result = await client.call("anyMethod")
|
||||
|
||||
@@ -39,8 +39,8 @@ async def test_call_no_data(client: FragmentClient) -> None:
|
||||
mock_request = AsyncMock(return_value={})
|
||||
|
||||
with (
|
||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.client.fragment_request", mock_request),
|
||||
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.domains.base.fragment_request", mock_request),
|
||||
):
|
||||
await client.call("anyMethod")
|
||||
|
||||
@@ -53,8 +53,8 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
||||
mock_request = AsyncMock(return_value={})
|
||||
|
||||
with (
|
||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.client.fragment_request", mock_request),
|
||||
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.domains.base.fragment_request", mock_request),
|
||||
):
|
||||
await client.call("anyMethod", {"key": "value", "num": 7})
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Cover Telegram Ads recharge flow, including request preparation and KYC handling."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_recharge_ads_mod = importlib.import_module("pyfragment.domains.ads.recharge")
|
||||
import pyfragment.domains.ads.recharge as _recharge_ads_mod
|
||||
from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
|
||||
Reference in New Issue
Block a user