mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
@@ -2,16 +2,14 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "**" ]
|
branches: ["**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "**" ]
|
branches: ["**"]
|
||||||
|
|
||||||
jobs:
|
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@v6
|
- uses: actions/checkout@v6
|
||||||
@@ -19,21 +17,20 @@ jobs:
|
|||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
- run: pip install ".[dev]"
|
- run: pip install ".[dev]"
|
||||||
|
|
||||||
- run: ruff check . && ruff format --check . && mypy pyfragment
|
- run: ruff check . && ruff format --check . && mypy pyfragment --explicit-package-bases
|
||||||
|
|
||||||
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.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:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
@@ -41,6 +38,7 @@ jobs:
|
|||||||
- uses: actions/setup-python@v6
|
- 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]"
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ name: Publish
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: [ "CI" ]
|
workflows: ["CI"]
|
||||||
types: [ completed ]
|
types: [completed]
|
||||||
branches: [ master ]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version-check:
|
version-check:
|
||||||
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 }}
|
||||||
@@ -42,8 +40,6 @@ 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@v6
|
- uses: actions/checkout@v6
|
||||||
@@ -63,10 +59,8 @@ jobs:
|
|||||||
|
|
||||||
publish:
|
publish:
|
||||||
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/
|
||||||
@@ -83,35 +77,21 @@ jobs:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
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@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: 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
|
- 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 }}
|
||||||
files: dist/*
|
files: dist/*
|
||||||
body: ${{ steps.changelog.outputs.body }}
|
generate_release_notes: true
|
||||||
make_latest: 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
|
## [2026.3.0] — 2026-05-21
|
||||||
|
|
||||||
### Changed
|
### 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">
|
<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;">
|
[](https://pypi.org/project/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://pepy.tech/projects/pyfragment)
|
||||||
</p>
|
[](https://python.org)
|
||||||
|
[](https://github.com/bohd4nx/pyfragment/actions)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
[](https://pypi.org/project/pyfragment/)
|
Async Python client for the **[Fragment](https://fragment.com)** marketplace API.
|
||||||
[](https://pepy.tech/projects/pyfragment)
|
Buy Stars & Premium, run giveaways, top up TON and Ads balances,
|
||||||
[](https://python.org)
|
manage anonymous numbers, and search Fragment listings.
|
||||||
[](https://github.com/bohd4nx/pyfragment/actions)
|
|
||||||
[](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE)
|
|
||||||
|
|
||||||
[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>
|
</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
|
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+.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Quick Start
|
||||||
|
|
||||||
| 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
|
||||||
@@ -96,34 +53,108 @@ async def main() -> None:
|
|||||||
},
|
},
|
||||||
) as client:
|
) as client:
|
||||||
wallet = await client.get_wallet()
|
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")
|
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")
|
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())
|
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">
|
<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>
|
</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 importlib.metadata import version
|
||||||
|
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
@@ -20,12 +21,14 @@ from pyfragment.exceptions import (
|
|||||||
)
|
)
|
||||||
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
|
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
|
||||||
from pyfragment.models.cookies import CookieResult
|
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.giveaways import PremiumGiveawayResult, StarsGiveawayResult
|
||||||
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
|
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
|
||||||
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
|
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
|
||||||
from pyfragment.models.wallet import WalletInfo
|
from pyfragment.models.wallet import WalletInfo
|
||||||
|
|
||||||
|
logging.getLogger("pyfragment").addHandler(logging.NullHandler())
|
||||||
|
|
||||||
__version__: str = version("pyfragment")
|
__version__: str = version("pyfragment")
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -62,5 +65,6 @@ __all__ = [
|
|||||||
"UnexpectedError",
|
"UnexpectedError",
|
||||||
# literal types
|
# literal types
|
||||||
"PaymentMethod",
|
"PaymentMethod",
|
||||||
|
"WalletVersion",
|
||||||
"get_cookies_from_browser",
|
"get_cookies_from_browser",
|
||||||
]
|
]
|
||||||
|
|||||||
+36
-26
@@ -3,12 +3,10 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from typing import Any, cast, get_args
|
from typing import Any, cast, get_args
|
||||||
|
|
||||||
import httpx
|
from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
|
||||||
|
|
||||||
from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL, REQUIRED_COOKIE_KEYS
|
|
||||||
from pyfragment.core.transport import fragment_request, get_fragment_hash
|
|
||||||
from pyfragment.domains.ads.service import AdsService
|
from pyfragment.domains.ads.service import AdsService
|
||||||
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.service import GiveawaysService
|
from pyfragment.domains.giveaways.service import GiveawaysService
|
||||||
from pyfragment.domains.marketplace.service import MarketplaceService
|
from pyfragment.domains.marketplace.service import MarketplaceService
|
||||||
from pyfragment.domains.purchases.service import PurchasesService
|
from pyfragment.domains.purchases.service import PurchasesService
|
||||||
@@ -31,7 +29,7 @@ class FragmentClient:
|
|||||||
connected with Fragment or Telegram.
|
connected with Fragment or Telegram.
|
||||||
|
|
||||||
Args:
|
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.
|
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).
|
||||||
@@ -53,14 +51,17 @@ class FragmentClient:
|
|||||||
print(result.transaction_id)
|
print(result.transaction_id)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
@staticmethod
|
||||||
self,
|
def _parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]:
|
||||||
seed: str,
|
if isinstance(cookies, str):
|
||||||
api_key: str,
|
try:
|
||||||
cookies: dict[str, Any] | str,
|
cookies = json.loads(cookies)
|
||||||
wallet_version: str = "V5R1",
|
except Exception as exc:
|
||||||
timeout: float = DEFAULT_TIMEOUT,
|
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
|
||||||
) -> None:
|
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()]
|
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
|
||||||
if missing:
|
if missing:
|
||||||
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
|
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
|
||||||
@@ -72,16 +73,14 @@ class FragmentClient:
|
|||||||
if len(api_key.strip()) < 68:
|
if len(api_key.strip()) < 68:
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
|
raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
|
||||||
|
|
||||||
if isinstance(cookies, str):
|
@staticmethod
|
||||||
try:
|
def _validate_cookie_keys(cookies: dict[str, Any]) -> None:
|
||||||
cookies = json.loads(cookies)
|
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
|
||||||
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:
|
if missing_keys:
|
||||||
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(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()
|
version = wallet_version.strip().upper()
|
||||||
if version not in get_args(WalletVersion):
|
if version not in get_args(WalletVersion):
|
||||||
raise ConfigurationError(
|
raise ConfigurationError(
|
||||||
@@ -89,11 +88,25 @@ class FragmentClient:
|
|||||||
version=version, supported=", ".join(sorted(get_args(WalletVersion)))
|
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.seed: str = seed.strip()
|
||||||
self.api_key: str = api_key.strip()
|
self.api_key: str = api_key.strip()
|
||||||
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.marketplace = MarketplaceService(self)
|
self.marketplace = MarketplaceService(self)
|
||||||
self.purchases = PurchasesService(self)
|
self.purchases = PurchasesService(self)
|
||||||
@@ -371,7 +384,4 @@ class FragmentClient:
|
|||||||
page_url="https://fragment.com/premium/gift",
|
page_url="https://fragment.com/premium/gift",
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
|
return await raw_api_call(self.cookies, self.timeout, method, data, 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 {})})
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from pyfragment.exceptions import CookieError
|
|||||||
from pyfragment.models.cookies import CookieResult
|
from pyfragment.models.cookies import CookieResult
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import rookiepy # type: ignore[import-not-found]
|
import rookiepy
|
||||||
except Exception:
|
except Exception: # noqa: BLE001
|
||||||
rookiepy = None
|
rookiepy = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
|
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
|
||||||
@@ -13,6 +14,9 @@ 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 (1 <= amount <= 1_000_000_000):
|
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
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)
|
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:
|
except FragmentError as exc:
|
||||||
|
logger.error("Failed to recharge Ads account '%s' for %s TON: %s", account, amount, exc, exc_info=True)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to recharge Ads account '%s' for %s TON due to an unexpected error", account, amount)
|
||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
|
||||||
@@ -21,6 +22,9 @@ 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:
|
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):
|
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
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)
|
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:
|
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
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to top up TON for user '%s' with %s TON due to an unexpected error", username, amount)
|
||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -12,6 +13,9 @@ 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
|
||||||
|
|
||||||
@@ -32,9 +36,11 @@ 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:
|
except FragmentError as exc:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -50,9 +56,21 @@ 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:
|
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
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +106,9 @@ 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:
|
except FragmentError as exc:
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,10 +1,39 @@
|
|||||||
from __future__ import annotations
|
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:
|
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,
|
||||||
|
) -> 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:
|
class BaseService:
|
||||||
def __init__(self, client: FragmentClient) -> None:
|
def __init__(self, client: FragmentClient) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
from typing import TYPE_CHECKING, get_args
|
from typing import TYPE_CHECKING, get_args
|
||||||
|
|
||||||
from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
|
from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
|
||||||
@@ -22,6 +24,9 @@ if TYPE_CHECKING:
|
|||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def giveaway_stars(
|
async def giveaway_stars(
|
||||||
client: FragmentClient,
|
client: FragmentClient,
|
||||||
channel: str,
|
channel: str,
|
||||||
@@ -47,6 +52,12 @@ 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": str(random.randint(100_000_000, 999_999_999))},
|
||||||
|
page_url=STARS_GIVEAWAY_PAGE,
|
||||||
|
)
|
||||||
|
|
||||||
result = await client.call(
|
result = await client.call(
|
||||||
"initGiveawayStarsRequest",
|
"initGiveawayStarsRequest",
|
||||||
{
|
{
|
||||||
@@ -84,9 +95,25 @@ 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:
|
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
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -119,6 +146,17 @@ 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": str(random.randint(100_000_000, 999_999_999)),
|
||||||
|
"quantity": "",
|
||||||
|
},
|
||||||
|
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||||
|
)
|
||||||
|
|
||||||
result = await client.call(
|
result = await client.call(
|
||||||
"initGiveawayPremiumRequest",
|
"initGiveawayPremiumRequest",
|
||||||
{
|
{
|
||||||
@@ -156,7 +194,23 @@ 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:
|
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
|
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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
@@ -11,6 +12,9 @@ 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 = "",
|
||||||
@@ -36,9 +40,19 @@ 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:
|
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
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -67,9 +81,19 @@ 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:
|
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
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +130,19 @@ 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:
|
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
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to search gifts for query '%s' due to an unexpected error", query)
|
||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, get_args
|
from typing import TYPE_CHECKING, get_args
|
||||||
|
|
||||||
@@ -23,6 +24,9 @@ if TYPE_CHECKING:
|
|||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def purchase_stars(
|
async def purchase_stars(
|
||||||
client: FragmentClient,
|
client: FragmentClient,
|
||||||
username: str,
|
username: str,
|
||||||
@@ -84,9 +88,23 @@ 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:
|
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
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -151,7 +169,21 @@ 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:
|
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
|
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
|
||||||
|
|||||||
@@ -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.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__ = [
|
__all__ = [
|
||||||
"TonapiService",
|
"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
|
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
|
||||||
@@ -16,6 +17,9 @@ if TYPE_CHECKING:
|
|||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
|
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
|
||||||
"""Return the USDT balance for a Fragment-linked TON wallet."""
|
"""Return the USDT balance for a Fragment-linked TON wallet."""
|
||||||
try:
|
try:
|
||||||
@@ -29,9 +33,12 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +54,11 @@ async def check_ton_payment_balance(
|
|||||||
|
|
||||||
required_ton = max(tx_price_ton, MIN_TON_BALANCE)
|
required_ton = max(tx_price_ton, MIN_TON_BALANCE)
|
||||||
if balance_ton < required_ton:
|
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))
|
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=required_ton))
|
||||||
|
|
||||||
|
|
||||||
@@ -58,11 +70,22 @@ async def check_usdt_payment_balance(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Validate that the wallet can cover a USDT-denominated payment."""
|
"""Validate that the wallet can cover a USDT-denominated payment."""
|
||||||
if balance_ton < MIN_TON_BALANCE:
|
if balance_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))
|
raise WalletError(WalletError.LOW_TON_BALANCE.format(balance=balance_ton, required=MIN_TON_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))
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +103,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -99,4 +123,5 @@ async def get_wallet_info(client: FragmentClient) -> WalletInfo:
|
|||||||
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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
import random
|
import random
|
||||||
import ssl
|
import ssl
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -19,6 +20,9 @@ if TYPE_CHECKING:
|
|||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def clean_decode(payload: str) -> str | Cell:
|
def clean_decode(payload: str) -> str | Cell:
|
||||||
"""Decode a base64 BOC comment from Fragment into text when possible.
|
"""Decode a base64 BOC comment from Fragment into text when possible.
|
||||||
|
|
||||||
@@ -43,9 +47,81 @@ def clean_decode(payload: str) -> str | Cell:
|
|||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
return cell
|
return cell
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to decode Fragment payload")
|
||||||
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
|
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(
|
async def process_transaction(
|
||||||
client: FragmentClient,
|
client: FragmentClient,
|
||||||
transaction_data: dict[str, Any],
|
transaction_data: dict[str, Any],
|
||||||
@@ -63,62 +139,32 @@ async def process_transaction(
|
|||||||
Returns:
|
Returns:
|
||||||
Normalized transaction hash string.
|
Normalized transaction hash string.
|
||||||
"""
|
"""
|
||||||
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
|
message = _extract_message(transaction_data)
|
||||||
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
|
||||||
|
|
||||||
message = transaction_data["transaction"]["messages"][0]
|
|
||||||
amount_ton = int(message["amount"]) / 1_000_000_000
|
amount_ton = int(message["amount"]) / 1_000_000_000
|
||||||
|
|
||||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||||
|
|
||||||
# Check balance first so we fail before trying to broadcast on-chain.
|
await _check_payment_balances(wallet, payment_method, amount_ton, required_payment_amount, transaction_data, ton)
|
||||||
try:
|
|
||||||
await wallet.refresh()
|
payload = clean_decode(str(message.get("payload", "")))
|
||||||
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:
|
try:
|
||||||
raw_payload = str(message.get("payload", ""))
|
return await _broadcast_with_retry(wallet, message, 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):
|
except (WalletError, TransactionError):
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
cause: BaseException | None = exc
|
cause: BaseException | None = exc
|
||||||
while cause is not None:
|
while cause is not None:
|
||||||
if isinstance(cause, ssl.SSLError):
|
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
|
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
|
||||||
cause = cause.__cause__ or cause.__context__
|
cause = cause.__cause__ or cause.__context__
|
||||||
|
logger.exception(
|
||||||
|
"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=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.giveaways import PremiumGiveawayResult, StarsGiveawayResult
|
||||||
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
|
from pyfragment.models.marketplace import GiftsResult, NumbersResult, UsernamesResult
|
||||||
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
|
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult, PremiumResult, StarsResult
|
||||||
from pyfragment.models.wallet import TonTransferResult, UsdtTransferResult, WalletInfo
|
from pyfragment.models.wallet import WalletInfo
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AdsRechargeResult",
|
"AdsRechargeResult",
|
||||||
@@ -19,9 +19,7 @@ __all__ = [
|
|||||||
"StarsGiveawayResult",
|
"StarsGiveawayResult",
|
||||||
"StarsResult",
|
"StarsResult",
|
||||||
"TerminateSessionsResult",
|
"TerminateSessionsResult",
|
||||||
"TonTransferResult",
|
|
||||||
"UsernamesResult",
|
"UsernamesResult",
|
||||||
"UsdtTransferResult",
|
|
||||||
"WalletInfo",
|
"WalletInfo",
|
||||||
"WalletVersion",
|
"WalletVersion",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,24 +17,4 @@ class WalletInfo:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
__all__ = ["WalletInfo"]
|
||||||
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"]
|
|
||||||
|
|||||||
+15
-8
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pyfragment"
|
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."
|
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" }
|
||||||
@@ -21,7 +21,6 @@ keywords = [
|
|||||||
"telegram-ads",
|
"telegram-ads",
|
||||||
"ton",
|
"ton",
|
||||||
"ton-blockchain",
|
"ton-blockchain",
|
||||||
"tonkeeper",
|
|
||||||
"tonapi",
|
"tonapi",
|
||||||
"anonymous-numbers",
|
"anonymous-numbers",
|
||||||
"username-auctions",
|
"username-auctions",
|
||||||
@@ -36,7 +35,6 @@ keywords = [
|
|||||||
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",
|
"Natural Language :: English",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
@@ -45,13 +43,13 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.10",
|
"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",
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
"Topic :: Internet",
|
"Topic :: Internet",
|
||||||
"Topic :: Internet :: WWW/HTTP",
|
"Topic :: Internet :: WWW/HTTP",
|
||||||
"Topic :: Office/Business :: Financial",
|
|
||||||
"Topic :: Office/Business :: Financial :: Investment",
|
|
||||||
"Typing :: Typed",
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
dependencies = ["httpx>=0.25", "tonutils>=2.0.1"]
|
dependencies = ["httpx>=0.25", "tonutils>=2.0.1"]
|
||||||
@@ -78,7 +76,7 @@ addopts = "-v --tb=short"
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 128
|
line-length = 128
|
||||||
target-version = "py312"
|
target-version = "py310"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
|
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
|
||||||
@@ -89,9 +87,18 @@ 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.10"
|
python_version = "3.10"
|
||||||
strict = true
|
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."""
|
"""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
|
||||||
|
|
||||||
_purchase_stars_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
|
import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod
|
||||||
_giveaway_stars_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
|
import pyfragment.domains.purchases.purchase as _purchase_stars_mod
|
||||||
from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError
|
from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError
|
||||||
from 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
|
||||||
|
|
||||||
@@ -165,6 +164,7 @@ 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,
|
||||||
]
|
]
|
||||||
@@ -187,6 +187,7 @@ 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,
|
||||||
]
|
]
|
||||||
@@ -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")
|
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[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
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
"""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
|
||||||
|
|
||||||
_purchase_premium_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
|
import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod
|
||||||
_giveaway_premium_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
|
import pyfragment.domains.purchases.purchase as _purchase_premium_mod
|
||||||
from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError
|
from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError
|
||||||
from 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
|
||||||
|
|
||||||
@@ -150,6 +149,7 @@ 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,
|
||||||
]
|
]
|
||||||
@@ -172,6 +172,7 @@ 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,
|
||||||
]
|
]
|
||||||
@@ -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")
|
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[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
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"""Cover 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
|
||||||
|
|
||||||
_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 pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
patch("pyfragment.domains.base.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.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
patch("pyfragment.domains.base.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.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
patch("pyfragment.client.fragment_request", mock_request),
|
patch("pyfragment.domains.base.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.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
patch("pyfragment.client.fragment_request", mock_request),
|
patch("pyfragment.domains.base.fragment_request", mock_request),
|
||||||
):
|
):
|
||||||
await client.call("anyMethod", {"key": "value", "num": 7})
|
await client.call("anyMethod", {"key": "value", "num": 7})
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"""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
|
||||||
|
|
||||||
_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 pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user