mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4222799298 | |||
| 27ce28fd32 | |||
| b11b9f299b | |||
| 0a361c8c3a | |||
| f46551c905 | |||
| 2bd8cc177a | |||
| fd31342d03 | |||
| bdaf4a960d | |||
| d78be012a3 | |||
| ef831d12a0 | |||
| 380fe40ffd | |||
| 4539dc9803 | |||
| a9663d0b08 | |||
| 228fbfcd1c | |||
| dc9661134d | |||
| 9f11ccb3ee | |||
| 8f865f4911 | |||
| 3481769757 | |||
| b85b34f2b1 | |||
| d01c45d85b | |||
| 695430744a | |||
| afaa42776b | |||
| ed54d1f1a0 | |||
| 6aa7037380 | |||
| 5124af17ef | |||
| 66bcd22198 | |||
| 3d0cf38f13 | |||
| 5c472b68a2 | |||
| b2d06a7f22 | |||
| 34ee31e299 | |||
| c1ff78c484 | |||
| f8ad801e0c | |||
| 6cb6e3fe05 | |||
| d4e3d3491b | |||
| 4a7131acdb | |||
| 546bcb337c | |||
| 5f8197b710 | |||
| e9dd706fa6 | |||
| f8ac7f9594 | |||
| 3f5e0bc03f | |||
| 92384b96f0 | |||
| 516e69f820 | |||
| 2a6c5ef1f9 | |||
| 01a5befd87 | |||
| ae164d4fe7 | |||
| e7dc0e051e | |||
| e1478ec8b0 | |||
| 363e0719ab |
@@ -2,38 +2,35 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "**" ]
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: [ "**" ]
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint & Format
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.11"
|
||||
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.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]"
|
||||
|
||||
@@ -2,17 +2,15 @@ name: Publish
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ "CI" ]
|
||||
types: [ completed ]
|
||||
branches: [ master ]
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
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
|
||||
@@ -52,7 +48,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- uses: astral-sh/setup-uv@v8.1.0
|
||||
- uses: astral-sh/setup-uv@v8.2.0
|
||||
|
||||
- run: uv build
|
||||
|
||||
@@ -63,10 +59,8 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
needs: [ version-check, build ]
|
||||
needs: [version-check, build]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/pyfragment/
|
||||
@@ -83,35 +77,21 @@ jobs:
|
||||
|
||||
release:
|
||||
name: GitHub Release
|
||||
needs: [ version-check, build ]
|
||||
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
|
||||
|
||||
@@ -37,3 +37,7 @@ systests/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
uv.lock
|
||||
|
||||
#docs
|
||||
*-docs/
|
||||
|
||||
+113
-1
@@ -7,6 +7,114 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
||||
|
||||
---
|
||||
|
||||
## [2026.3.2] — 2026-06-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added `ApiProvider` enum with `TONAPI` (tonconsole.com, default) and `TONCENTER` (t.me/toncenter) values.
|
||||
- Added `api_provider` parameter to `FragmentClient` — select the blockchain API provider at init time (`"tonapi"` or `"toncenter"`).
|
||||
- Both providers accept `api_key` with the same interface; the correct `tonutils` client is selected automatically.
|
||||
|
||||
- New `AlreadySubscribedError` exception for Premium purchase flows when Fragment returns: `This account is already subscribed to Telegram Premium.`
|
||||
- New `UserNotFoundError.NOT_A_USER` message for when Fragment returns: `Please enter a username assigned to a user.` (e.g. when the username belongs to a channel or bot).
|
||||
- Added `WalletVersion.HighloadV2` and `WalletVersion.HighloadV3R1` to `WalletVersion`
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated purchase and giveaway flow state nonces (`dh`) to use nonce-like dynamic values with a wider integer range.
|
||||
- Stars and Premium giveaway flows now include explicit price update steps before init requests:
|
||||
- `updateStarsGiveawayPrices`
|
||||
- `updatePremiumGiveawayPrices`
|
||||
- Updated `DEVICE_INFO` fingerprint: Tonkeeper `appVersion` -> `26.05.0`.
|
||||
- Updated client docstrings and purchase examples to document all supported payment methods.
|
||||
|
||||
### Renamed — TON -> GRAM (ex TON)
|
||||
|
||||
The TON blockchain has been rebranded to **GRAM (ex TON)**. All identifiers, messages, and documentation have been updated accordingly.
|
||||
|
||||
**Public API**
|
||||
|
||||
- `FragmentClient.topup_ton()` → `topup_gram()`
|
||||
- `PaymentMethod.TON` → `PaymentMethod.GRAM`
|
||||
- `PaymentMethod.USDT_TON` → `PaymentMethod.USDT_GRAM`
|
||||
- `WalletInfo.ton_balance` → `WalletInfo.gram_balance`
|
||||
|
||||
**Constants**
|
||||
|
||||
- `TON_TOPUP_MIN` / `TON_TOPUP_MAX` → `GRAM_TOPUP_MIN` / `GRAM_TOPUP_MAX`
|
||||
- `MIN_TON_BALANCE` → `MIN_GRAM_BALANCE`
|
||||
- `USDT_TON_MASTER_ADDRESS` → `USDT_GRAM_MASTER_ADDRESS`
|
||||
|
||||
**Exceptions**
|
||||
|
||||
- `ConfigurationError.INVALID_TON_AMOUNT` → `INVALID_GRAM_AMOUNT`
|
||||
- `WalletError.LOW_TON_BALANCE` → `LOW_GRAM_BALANCE`
|
||||
- `WalletError.TON_BALANCE_CHECK_FAILED` → `GRAM_BALANCE_CHECK_FAILED`
|
||||
|
||||
**Internals**
|
||||
|
||||
- `pyfragment/core/constants/ton.py` → `gram.py`
|
||||
- `check_ton_payment_balance()` → `check_gram_payment_balance()`
|
||||
|
||||
---
|
||||
|
||||
## [2026.3.1] — 2026-05-29
|
||||
|
||||
### Added
|
||||
|
||||
- Python 3.13 and 3.14 are now officially supported and included in the CI test matrix and PyPI classifiers.
|
||||
- `WalletVersion` is now exported from the top-level `pyfragment` package.
|
||||
|
||||
### Changed
|
||||
|
||||
- `process_transaction` (internal) refactored into focused subfunctions: `_extract_message`, `_check_payment_balances`, `_broadcast_with_retry`.
|
||||
- `raw_api_call()` moved from `FragmentClient` into `pyfragment.domains.base` and exposed as a standalone helper.
|
||||
- `tonapi` domain internal helpers removed from public `__init__.py` exports; only `TonapiService` is exported.
|
||||
- README rewritten with badges, structured sections, and complete usage examples.
|
||||
- Added `CONTRIBUTING.md` and `SECURITY.md`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CI: `mypy` now runs with `--explicit-package-bases` to avoid false-positive import errors.
|
||||
- CI: `pip` dependency cache enabled to speed up workflow runs.
|
||||
- CI: `warn_unused_ignores` suppressed for `pyfragment.core.cookies` to handle the optional `rookiepy` dependency correctly across environments where the package may or may not be installed.
|
||||
- Publish workflow now uses `generate_release_notes: true` instead of manual changelog extraction.
|
||||
|
||||
### Removed
|
||||
|
||||
- `tonapi/transfer.py` and associated `TonTransferResult` / `UsdtTransferResult` models (internal, unused).
|
||||
|
||||
---
|
||||
|
||||
## [2026.3.0] — 2026-05-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Internal architecture reorganized around explicit domain packages:
|
||||
- TON account and balance helpers are now unified under `pyfragment.domains.tonapi.account`
|
||||
- service wrappers and operation modules are aligned by domain (`ads`, `purchases`, `giveaways`, `anonymous_numbers`, `marketplace`, `tonapi`)
|
||||
- Package exports were cleaned up for domain and model packages (`__init__.py`) to provide clearer public symbols.
|
||||
- Examples and system tests were updated to follow current public import paths and project structure.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `get_cookies_from_browser()` is now patch-friendly in tests (`pyfragment.core.cookies.rookiepy` can be mocked reliably).
|
||||
- Anonymous number `NOT_OWNED` error message wording was adjusted for test and backward-compatibility with existing matchers.
|
||||
|
||||
## [2026.2.3] — 2026-05-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed USDT payment flow: the USDT balance check now correctly targets the wallet linked to the Fragment account (`transaction["from"]`), not the signing seed wallet. These are two distinct addresses — the seed wallet only signs the transaction and covers TON gas fees, while USDT is withdrawn from the Fragment-linked wallet.
|
||||
- Fixed `clean_decode()` incorrectly treating binary TON cell payloads (e.g. jetton transfer messages with non-zero op codes) as text comments. Only cells with op code `0x00000000` are now decoded as snake-encoded UTF-8 strings; all other op codes return the raw `Cell` as-is.
|
||||
- Restored and correctly wired USDT balance validation so `WalletError` is raised before broadcasting when the Fragment-linked wallet has insufficient USDT.
|
||||
|
||||
### Note
|
||||
|
||||
- USDT (`usdt_ton`) payments require USDT to be held in the TON wallet that is linked to your Fragment account profile. The seed wallet configured in `FragmentClient` is only used to sign transactions and pay TON network fees.
|
||||
|
||||
---
|
||||
|
||||
## [2026.2.2] — 2026-05-11
|
||||
|
||||
### Added
|
||||
@@ -61,7 +169,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
||||
|
||||
- `get_cookies_from_browser(browser)` — extract Fragment session cookies directly from an installed browser (Chrome, Firefox, Edge, Brave, Arc, Opera, Safari, and more); no browser extension or manual copy-paste required
|
||||
```python
|
||||
from pyfragment.utils import get_cookies_from_browser
|
||||
from pyfragment import get_cookies_from_browser
|
||||
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
|
||||
client = FragmentClient(seed="...", api_key="...", cookies=result.cookies)
|
||||
print(result.expires) # ISO 8601 expiry of stel_ssid, or None for session cookies
|
||||
@@ -163,6 +271,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
||||
- `py.typed` marker — full PEP 561 typing support for type-checkers
|
||||
- `__repr__` on all result types for readable debug output
|
||||
|
||||
[2026.3.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.2
|
||||
[2026.3.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.1
|
||||
[2026.3.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.0
|
||||
[2026.2.3]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.3
|
||||
[2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2
|
||||
[2026.2.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.1
|
||||
[2026.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Contributing to pyfragment
|
||||
|
||||
## Development setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bohd4nx/pyfragment.git
|
||||
cd pyfragment
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Running checks
|
||||
|
||||
```bash
|
||||
# Lint and format
|
||||
ruff check . --fix && ruff format .
|
||||
|
||||
# Type check
|
||||
mypy . --explicit-package-bases
|
||||
|
||||
# Tests
|
||||
pytest
|
||||
```
|
||||
|
||||
All three must pass before opening a PR.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
pyfragment/
|
||||
client.py — FragmentClient (public entry point)
|
||||
enums.py — ApiProvider, PaymentMethod, WalletVersion
|
||||
exceptions.py — exception hierarchy
|
||||
core/ — constants, validation helpers
|
||||
domains/ — one package per feature domain
|
||||
ads/ — recharge_ads, topup_gram
|
||||
anonymous_numbers/— get_login_code, toggle_login_codes, terminate_sessions
|
||||
giveaways/ — giveaway_stars, giveaway_premium
|
||||
marketplace/ — search_usernames, search_numbers, search_gifts
|
||||
purchases/ — purchase_stars, purchase_premium
|
||||
services/ — shared infrastructure services
|
||||
cookies/ — browser cookie extraction (models + service)
|
||||
tonapi/ — wallet info, transaction signing (tonapi/toncenter)
|
||||
tests/ — unit tests (pytest)
|
||||
examples/ — runnable usage examples (excluded from CI)
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- All public async methods live on `FragmentClient` and delegate to a domain service.
|
||||
- Domain functions receive a `FragmentClient` instance, never raw HTTP clients.
|
||||
- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.services.tonapi.transaction._make_ton_client`.
|
||||
- Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- Keep PRs focused — one feature or fix per PR.
|
||||
- Update `CHANGELOG.md` under `[Unreleased]`.
|
||||
- Add or update tests for any changed behaviour.
|
||||
@@ -1,25 +1,21 @@
|
||||
<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.com](https://fragment.com)** marketplace API.
|
||||
|
||||
[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)**
|
||||
|
||||
</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).
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -27,96 +23,43 @@
|
||||
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) — use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed:
|
||||
```python
|
||||
from pyfragment.utils 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
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.enums import PaymentMethod
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(
|
||||
seed="word1 word2 ... word24",
|
||||
api_key="YOUR_TONAPI_KEY",
|
||||
cookies={
|
||||
"stel_ssid": "...",
|
||||
"stel_dt": "...",
|
||||
"stel_token": "...",
|
||||
"stel_ton_token": "...",
|
||||
},
|
||||
api_key="YOUR_API_KEY",
|
||||
cookies={"stel_ssid": "...", "stel_dt": "...", "stel_token": "...", "stel_ton_token": "..."},
|
||||
) as client:
|
||||
wallet = await client.get_wallet()
|
||||
print(f"Wallet: {wallet.address} | TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
|
||||
print("GRAM: %s | USDT: %s" % (wallet.gram_balance, wallet.usdt_balance))
|
||||
|
||||
recipient = "https://t.me/username" # also supports: @username, username
|
||||
stars = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.USDT_GRAM)
|
||||
print("Sent %s Stars to %s | tx: %s" % (stars.amount, stars.username, stars.transaction_id))
|
||||
|
||||
stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton")
|
||||
print(f"Stars sent: {stars.amount} to {stars.username} | tx: {stars.transaction_id}")
|
||||
|
||||
premium = await client.purchase_premium(recipient, months=6, payment_method="ton")
|
||||
print(f"Premium sent: {premium.amount} months to {premium.username} | tx: {premium.transaction_id}")
|
||||
premium = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM)
|
||||
print("Sent Premium %sm to %s | tx: %s" % (premium.amount, premium.username, premium.transaction_id))
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Full runnable examples:
|
||||
|
||||
- https://github.com/bohd4nx/pyfragment/tree/master/examples
|
||||
|
||||
Payload debug/decode helper (thanks):
|
||||
|
||||
- https://ton-cell-abi-viewer.vercel.app/
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
### Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx)
|
||||
|
||||
**Star ⭐ this repo if you found it useful!**
|
||||
[Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
|
||||
|
||||
</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 (GRAM (ex TON) seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity.
|
||||
@@ -11,10 +11,9 @@ import asyncio
|
||||
import json
|
||||
|
||||
from pyfragment import FragmentClient, GiftsResult
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -34,7 +33,13 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER)
|
||||
|
||||
print(f"Found {len(result.items)} result(s):")
|
||||
|
||||
@@ -10,10 +10,9 @@ import asyncio
|
||||
import json
|
||||
|
||||
from pyfragment import FragmentClient, NumbersResult
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -32,7 +31,13 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER)
|
||||
|
||||
print(f"Found {len(result.items)} result(s):")
|
||||
|
||||
@@ -10,10 +10,9 @@ import asyncio
|
||||
import json
|
||||
|
||||
from pyfragment import FragmentClient, UsernamesResult
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -32,7 +31,13 @@ FILTER = "auction" # "", "auction", "sale", "sold" — or omit
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER)
|
||||
|
||||
print(f"Found {len(result.items)} result(s):")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Example: extract Fragment cookies directly from your browser.
|
||||
|
||||
get_cookies_from_browser() reads the Fragment session cookies from a locally
|
||||
installed browser — no manual copy-paste required.
|
||||
|
||||
Supported browsers: arc, brave, chrome, chromium, chromium_based, edge,
|
||||
firefox, firefox_based, librewolf, opera, opera_gx,
|
||||
safari, vivaldi.
|
||||
|
||||
The returned CookieResult.cookies dict can be passed directly to FragmentClient.
|
||||
"""
|
||||
|
||||
from pyfragment import CookieError, get_cookies_from_browser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
|
||||
except CookieError as e:
|
||||
print(f"Could not read cookies: {e}")
|
||||
return
|
||||
|
||||
print(f"Cookies expire: {result.expires}")
|
||||
print(f"Keys found: {list(result.cookies.keys())}")
|
||||
|
||||
# Pass the extracted cookies directly to FragmentClient
|
||||
# async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=result.cookies) as client:
|
||||
# ...
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -12,10 +12,9 @@ Defaults to the Fragment base URL.
|
||||
import asyncio
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -34,7 +33,13 @@ PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragmen
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
result = await client.call(METHOD, DATA, page_url=PAGE_URL)
|
||||
print(result)
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"""
|
||||
Example: fetch wallet address, state, and separate TON/USDT balances.
|
||||
Example: fetch wallet address, state, and separate GRAM (ex TON)/USDT balances.
|
||||
|
||||
Cookies can be passed as a dict or as a JSON string.
|
||||
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
|
||||
api_provider defaults to "tonapi" (tonconsole.com) — pass "toncenter" to use t.me/toncenter instead.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -30,12 +30,13 @@ async def main() -> None:
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2"
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
wallet = await client.get_wallet()
|
||||
print(f"Address: {wallet.address}")
|
||||
print(f"State: {wallet.state}")
|
||||
print(f"Balance: {wallet.ton_balance} TON")
|
||||
print(f"Balance: {wallet.gram_balance} GRAM (ex TON)")
|
||||
print(f"Balance: {wallet.usdt_balance} USDT")
|
||||
|
||||
|
||||
|
||||
@@ -9,10 +9,9 @@ Use terminate_sessions() to forcefully end all active Telegram sessions.
|
||||
import asyncio
|
||||
|
||||
from pyfragment import AnonymousNumberError, FragmentClient
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -29,7 +28,13 @@ NUMBER = "+88888888888"
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
# Fetch the latest login code
|
||||
result = await client.get_login_code(NUMBER)
|
||||
if result.code:
|
||||
|
||||
@@ -3,17 +3,16 @@ Example: run a Telegram Premium giveaway for a channel.
|
||||
|
||||
winners must be an integer between 1 and 24 000.
|
||||
months (Premium duration per winner) must be 3, 6, or 12.
|
||||
payment_method can be "ton" or "usdt_ton".
|
||||
Channel can be "@channel", "channel", or "https://t.me/channel".
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
from pyfragment.enums import PaymentMethod
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -29,11 +28,17 @@ COOKIES = {
|
||||
CHANNEL = "https://t.me/channel"
|
||||
WINNERS = 10 # 1–24 000
|
||||
MONTHS = 3 # 3, 6 or 12
|
||||
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
|
||||
PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
try:
|
||||
result = await client.giveaway_premium(
|
||||
CHANNEL,
|
||||
@@ -3,17 +3,16 @@ Example: purchase Telegram Premium for a user.
|
||||
|
||||
Supported durations: 3, 6, or 12 months.
|
||||
Set show_sender=False to send anonymously.
|
||||
payment_method can be "ton" or "usdt_ton".
|
||||
Username can be "@username", "username", or "https://t.me/username".
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
from pyfragment.enums import PaymentMethod
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -28,11 +27,17 @@ COOKIES = {
|
||||
|
||||
USERNAME = "https://t.me/username"
|
||||
MONTHS = 3 # 3, 6 or 12
|
||||
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
|
||||
PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
try:
|
||||
result = await client.purchase_premium(
|
||||
USERNAME,
|
||||
@@ -1,19 +1,18 @@
|
||||
"""
|
||||
Example: purchase Telegram Stars for a user.
|
||||
|
||||
Amount must be an integer between 50 and 1 000 000.
|
||||
Amount must be an integer between 50 and 10 000 000.
|
||||
Set show_sender=False to send anonymously.
|
||||
payment_method can be "ton" or "usdt_ton".
|
||||
Username can be "@username", "username", or "https://t.me/username".
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
from pyfragment.enums import PaymentMethod
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -27,12 +26,18 @@ COOKIES = {
|
||||
}
|
||||
|
||||
USERNAME = "https://t.me/username"
|
||||
AMOUNT = 500 # 50–1 000 000 stars
|
||||
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
|
||||
AMOUNT = 500 # 50–10 000 000 stars
|
||||
PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
try:
|
||||
result = await client.purchase_stars(
|
||||
USERNAME,
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Example: recharge your own Telegram Ads account with TON.
|
||||
Example: recharge your own Telegram Ads account with GRAM (ex TON).
|
||||
|
||||
Amount must be an integer between 1 and 1 000 000 000 TON.
|
||||
Your wallet must satisfy the current minimum TON threshold and transaction cost.
|
||||
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON).
|
||||
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -13,10 +13,9 @@ from pyfragment import (
|
||||
FragmentClient,
|
||||
WalletError,
|
||||
)
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -30,11 +29,17 @@ COOKIES = {
|
||||
}
|
||||
|
||||
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
|
||||
AMOUNT = 10 # 1–1 000 000 000 TON
|
||||
AMOUNT = 10 # 1–1 000 000 000 GRAM (ex TON)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
try:
|
||||
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
|
||||
except WalletError as e:
|
||||
@@ -44,7 +49,7 @@ async def main() -> None:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
|
||||
print(f"{result.amount} GRAM (ex TON) recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
"""
|
||||
Example: run a Telegram Stars giveaway for a channel.
|
||||
|
||||
winners must be an integer between 1 and 5.
|
||||
winners must be an integer between 1 and 15.
|
||||
amount (stars per winner) must be an integer between 500 and 1 000 000.
|
||||
payment_method can be "ton" or "usdt_ton".
|
||||
Channel can be "@channel", "channel", or "https://t.me/channel".
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
from pyfragment.enums import PaymentMethod
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -27,13 +26,19 @@ COOKIES = {
|
||||
}
|
||||
|
||||
CHANNEL = "https://t.me/channel"
|
||||
WINNERS = 3 # 1–5
|
||||
WINNERS = 3 # 1–15
|
||||
AMOUNT = 1000 # 500–1 000 000 stars per winner
|
||||
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
|
||||
PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
try:
|
||||
result = await client.giveaway_stars(
|
||||
CHANNEL,
|
||||
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
Example: top up TON to a recipient's Telegram balance.
|
||||
Example: top up GRAM (ex TON) to a recipient's Telegram balance.
|
||||
|
||||
For adding TON to a Telegram Ads account, use recharge_ads() instead.
|
||||
For adding GRAM (ex TON) to a Telegram Ads account, use recharge_ads() instead.
|
||||
|
||||
Amount must be an integer between 1 and 1 000 000 000 TON.
|
||||
Your wallet must satisfy the current minimum TON threshold and transaction cost.
|
||||
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON).
|
||||
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -15,10 +15,9 @@ from pyfragment import (
|
||||
UserNotFoundError,
|
||||
WalletError,
|
||||
)
|
||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter
|
||||
|
||||
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||
@@ -32,13 +31,19 @@ COOKIES = {
|
||||
}
|
||||
|
||||
USERNAME = "@username"
|
||||
AMOUNT = 10 # 1–1 000 000 000 TON
|
||||
AMOUNT = 10 # 1–1 000 000 000 GRAM (ex TON)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
async with FragmentClient(
|
||||
seed=SEED,
|
||||
api_key=API_KEY,
|
||||
cookies=COOKIES,
|
||||
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||
api_provider="tonapi", # or "toncenter"
|
||||
) as client:
|
||||
try:
|
||||
result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True)
|
||||
result = await client.topup_gram(USERNAME, amount=AMOUNT, show_sender=True)
|
||||
except UserNotFoundError:
|
||||
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||
return
|
||||
@@ -49,7 +54,7 @@ async def main() -> None:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}")
|
||||
print(f"{result.amount} GRAM (ex TON) successfully topped up for {result.username} | tx: {result.transaction_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
+17
-23
@@ -1,44 +1,34 @@
|
||||
# Copyright (c) 2026 bohd4nx
|
||||
#
|
||||
# This source code is licensed under the MIT License found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import logging
|
||||
from importlib.metadata import version
|
||||
|
||||
from pyfragment.client import FragmentClient
|
||||
from pyfragment.types import (
|
||||
AdsRechargeResult,
|
||||
AdsTopupResult,
|
||||
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult
|
||||
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
|
||||
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
|
||||
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
|
||||
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
|
||||
from pyfragment.enums import ApiProvider, PaymentMethod, WalletVersion
|
||||
from pyfragment.exceptions import (
|
||||
AlreadySubscribedError,
|
||||
AnonymousNumberError,
|
||||
ClientError,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
CookieResult,
|
||||
FragmentAPIError,
|
||||
# exceptions
|
||||
FragmentError,
|
||||
FragmentPageError,
|
||||
GiftsResult,
|
||||
LoginCodeResult,
|
||||
NumbersResult,
|
||||
OperationError,
|
||||
ParseError,
|
||||
# literal types
|
||||
PaymentMethod,
|
||||
PremiumGiveawayResult,
|
||||
PremiumResult,
|
||||
StarsGiveawayResult,
|
||||
# results
|
||||
StarsResult,
|
||||
TerminateSessionsResult,
|
||||
TransactionError,
|
||||
UnexpectedError,
|
||||
UsernamesResult,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
WalletError,
|
||||
WalletInfo,
|
||||
)
|
||||
from pyfragment.services.cookies import CookieResult, get_cookies_from_browser
|
||||
from pyfragment.services.tonapi.models import WalletInfo
|
||||
|
||||
logging.getLogger("pyfragment").addHandler(logging.NullHandler())
|
||||
|
||||
__version__: str = version("pyfragment")
|
||||
|
||||
@@ -64,6 +54,7 @@ __all__ = [
|
||||
"FragmentAPIError",
|
||||
"FragmentPageError",
|
||||
"ConfigurationError",
|
||||
"AlreadySubscribedError",
|
||||
"UserNotFoundError",
|
||||
"WalletError",
|
||||
"VerificationError",
|
||||
@@ -75,5 +66,8 @@ __all__ = [
|
||||
"ParseError",
|
||||
"UnexpectedError",
|
||||
# literal types
|
||||
"ApiProvider",
|
||||
"PaymentMethod",
|
||||
"WalletVersion",
|
||||
"get_cookies_from_browser",
|
||||
]
|
||||
|
||||
+103
-163
@@ -1,46 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
|
||||
from pyfragment.methods.giveaway_premium import giveaway_premium
|
||||
from pyfragment.methods.giveaway_stars import giveaway_stars
|
||||
from pyfragment.methods.purchase_premium import purchase_premium
|
||||
from pyfragment.methods.purchase_stars import purchase_stars
|
||||
from pyfragment.methods.recharge_ads import recharge_ads
|
||||
from pyfragment.methods.search_gifts import search_gifts
|
||||
from pyfragment.methods.search_numbers import search_numbers
|
||||
from pyfragment.methods.search_usernames import search_usernames
|
||||
from pyfragment.methods.topup_ton import topup_ton
|
||||
from pyfragment.types import (
|
||||
AdsRechargeResult,
|
||||
AdsTopupResult,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
GiftsResult,
|
||||
LoginCodeResult,
|
||||
NumbersResult,
|
||||
PremiumGiveawayResult,
|
||||
PremiumResult,
|
||||
StarsGiveawayResult,
|
||||
StarsResult,
|
||||
TerminateSessionsResult,
|
||||
UsernamesResult,
|
||||
WalletInfo,
|
||||
from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
|
||||
from pyfragment.core.validation import (
|
||||
normalize_provider,
|
||||
normalize_wallet_version,
|
||||
parse_cookies,
|
||||
validate_cookie_keys,
|
||||
validate_credentials,
|
||||
)
|
||||
from pyfragment.types.constants import (
|
||||
DEFAULT_TIMEOUT,
|
||||
FRAGMENT_BASE_URL,
|
||||
REQUIRED_COOKIE_KEYS,
|
||||
SUPPORTED_WALLET_VERSIONS,
|
||||
PaymentMethod,
|
||||
WalletVersion,
|
||||
)
|
||||
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
|
||||
from pyfragment.utils.wallet import get_wallet_info
|
||||
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult
|
||||
from pyfragment.domains.ads.service import AdsService
|
||||
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
|
||||
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
|
||||
from pyfragment.domains.base import raw_api_call
|
||||
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
|
||||
from pyfragment.domains.giveaways.service import GiveawaysService
|
||||
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
|
||||
from pyfragment.domains.marketplace.service import MarketplaceService
|
||||
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
|
||||
from pyfragment.domains.purchases.service import PurchasesService
|
||||
from pyfragment.enums import ApiProvider, PaymentMethod, WalletVersion
|
||||
from pyfragment.services.tonapi.models import WalletInfo
|
||||
from pyfragment.services.tonapi.service import TonapiService
|
||||
|
||||
|
||||
class FragmentClient:
|
||||
@@ -52,14 +35,18 @@ class FragmentClient:
|
||||
connected with Fragment or Telegram.
|
||||
|
||||
Args:
|
||||
seed: 24-word mnemonic phrase for the TON wallet.
|
||||
api_key: Tonapi API key — get one at https://tonconsole.com.
|
||||
seed: 12- or 24-word mnemonic phrase for the GRAM (ex TON) wallet.
|
||||
api_key: API key for the chosen provider — tonconsole.com (default) or t.me/toncenter.
|
||||
cookies: Fragment session cookies as a dict or JSON string.
|
||||
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
|
||||
api_provider: Blockchain API provider — ``"tonapi"`` (tonconsole.com, default)
|
||||
or ``"toncenter"`` (t.me/toncenter).
|
||||
timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
|
||||
headers: Custom HTTP request headers. If omitted, :data:`BASE_HEADERS` is used.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
|
||||
ConfigurationError: If ``seed``, ``api_key``, ``wallet_version``, or ``api_provider``
|
||||
are missing or invalid.
|
||||
CookieError: If ``cookies`` cannot be parsed or are missing required keys.
|
||||
|
||||
Example::
|
||||
@@ -80,42 +67,29 @@ class FragmentClient:
|
||||
api_key: str,
|
||||
cookies: dict[str, Any] | str,
|
||||
wallet_version: str = "V5R1",
|
||||
api_provider: str = "tonapi",
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
|
||||
if missing:
|
||||
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
|
||||
|
||||
word_count = len(seed.split())
|
||||
if word_count not in (12, 18, 24):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
|
||||
|
||||
if len(api_key.strip()) < 68:
|
||||
raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
|
||||
|
||||
if isinstance(cookies, str):
|
||||
try:
|
||||
cookies = json.loads(cookies)
|
||||
except Exception as exc:
|
||||
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
|
||||
|
||||
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict[str, Any], cookies).get(k, "")).strip()]
|
||||
if missing_keys:
|
||||
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
|
||||
|
||||
version = wallet_version.strip().upper()
|
||||
if version not in SUPPORTED_WALLET_VERSIONS:
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.UNSUPPORTED_VERSION.format(
|
||||
version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS))
|
||||
)
|
||||
)
|
||||
validate_credentials(seed, api_key)
|
||||
provider = normalize_provider(api_provider)
|
||||
parsed_cookies = parse_cookies(cookies)
|
||||
validate_cookie_keys(parsed_cookies)
|
||||
version = 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.api_provider: ApiProvider = provider
|
||||
self.cookies: dict[str, Any] = parsed_cookies
|
||||
self.wallet_version: WalletVersion = version
|
||||
self.timeout: float = timeout
|
||||
self.headers: dict[str, str] = headers if headers is not None else BASE_HEADERS
|
||||
self.marketplace = MarketplaceService(self)
|
||||
self.purchases = PurchasesService(self)
|
||||
self.giveaways = GiveawaysService(self)
|
||||
self.tonapi = TonapiService(self)
|
||||
self.anonymous_numbers = AnonymousNumbersService(self)
|
||||
self.ads = AdsService(self)
|
||||
|
||||
async def __aenter__(self) -> FragmentClient:
|
||||
return self
|
||||
@@ -124,14 +98,14 @@ class FragmentClient:
|
||||
pass
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)"
|
||||
return f"FragmentClient(wallet_version='{self.wallet_version}', api_provider='{self.api_provider}', cookies={len(self.cookies)} keys)"
|
||||
|
||||
async def purchase_premium(
|
||||
self,
|
||||
username: str,
|
||||
months: int,
|
||||
show_sender: bool = True,
|
||||
payment_method: PaymentMethod = "ton",
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> PremiumResult:
|
||||
"""Gift Telegram Premium to a user.
|
||||
|
||||
@@ -139,122 +113,117 @@ class FragmentClient:
|
||||
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||
months: Duration — ``3``, ``6``, or ``12``.
|
||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
|
||||
|
||||
Returns:
|
||||
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
"""
|
||||
return await purchase_premium(self, username, months, show_sender, payment_method)
|
||||
return await self.purchases.purchase_premium(username, months, show_sender=show_sender, payment_method=payment_method)
|
||||
|
||||
async def purchase_stars(
|
||||
self,
|
||||
username: str,
|
||||
amount: int,
|
||||
show_sender: bool = True,
|
||||
payment_method: PaymentMethod = "ton",
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> StarsResult:
|
||||
"""Send Telegram Stars to a user.
|
||||
|
||||
Args:
|
||||
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||
amount: Number of stars — integer from ``50`` to ``1 000 000``.
|
||||
amount: Number of stars — integer from ``50`` to ``10 000 000``.
|
||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
|
||||
|
||||
Returns:
|
||||
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
"""
|
||||
return await purchase_stars(self, username, amount, show_sender, payment_method)
|
||||
return await self.purchases.purchase_stars(username, amount, show_sender=show_sender, payment_method=payment_method)
|
||||
|
||||
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||
"""Top up TON to a recipient's Telegram balance.
|
||||
async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||
"""Top up GRAM (ex TON) to a recipient's Telegram balance.
|
||||
|
||||
Args:
|
||||
username: Recipient's Telegram username (with or without ``@``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``.
|
||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
"""
|
||||
return await topup_ton(self, username, amount, show_sender)
|
||||
return await self.ads.topup_gram(username, amount, show_sender=show_sender)
|
||||
|
||||
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
|
||||
"""Add funds to your own Telegram Ads account.
|
||||
|
||||
Args:
|
||||
account: Your Fragment Ads account identifier — the channel or bot username
|
||||
the Ads account is linked to (e.g. ``"@mychannel"``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
account: Channel or bot username the Ads account is linked to (e.g. ``"@mychannel"``).
|
||||
amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
|
||||
"""
|
||||
return await recharge_ads(self, account, amount)
|
||||
return await self.ads.recharge_ads(account, amount)
|
||||
|
||||
async def get_wallet(self) -> WalletInfo:
|
||||
"""Return the address, state, and balances of the wallet.
|
||||
|
||||
Returns:
|
||||
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
|
||||
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
|
||||
``ton_balance`` in TON, and ``usdt_balance`` in USDT.
|
||||
:class:`WalletInfo` with ``address``, ``state``, ``gram_balance``, and ``usdt_balance``.
|
||||
"""
|
||||
return await get_wallet_info(self)
|
||||
return await self.tonapi.get_wallet()
|
||||
|
||||
async def giveaway_stars(
|
||||
self,
|
||||
channel: str,
|
||||
winners: int,
|
||||
amount: int,
|
||||
payment_method: PaymentMethod = "ton",
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> StarsGiveawayResult:
|
||||
"""Run a Telegram Stars giveaway for a channel.
|
||||
|
||||
Args:
|
||||
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||
winners: Number of winners — integer from ``1`` to ``5``.
|
||||
winners: Number of winners — integer from ``1`` to ``15``.
|
||||
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
||||
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
|
||||
|
||||
Returns:
|
||||
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
|
||||
``winners``, and ``amount``.
|
||||
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``.
|
||||
"""
|
||||
return await giveaway_stars(self, channel, winners, amount, payment_method)
|
||||
return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method)
|
||||
|
||||
async def giveaway_premium(
|
||||
self,
|
||||
channel: str,
|
||||
winners: int,
|
||||
months: int = 3,
|
||||
payment_method: PaymentMethod = "ton",
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> PremiumGiveawayResult:
|
||||
"""Run a Telegram Premium giveaway for a channel.
|
||||
|
||||
Args:
|
||||
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||
winners: Number of winners — positive integer.
|
||||
winners: Number of winners — integer from ``1`` to ``24 000``.
|
||||
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
||||
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
|
||||
|
||||
Returns:
|
||||
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
|
||||
``winners``, and ``amount``.
|
||||
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``.
|
||||
"""
|
||||
return await giveaway_premium(self, channel, winners, months, payment_method)
|
||||
return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method)
|
||||
|
||||
async def get_login_code(self, number: str) -> LoginCodeResult:
|
||||
"""Fetch the current pending login code for an anonymous number.
|
||||
|
||||
Args:
|
||||
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
|
||||
number: Phone number with or without leading ``+``.
|
||||
|
||||
Returns:
|
||||
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
|
||||
and ``active_sessions`` count.
|
||||
"""
|
||||
return await get_login_code(self, number)
|
||||
return await self.anonymous_numbers.get_login_code(number)
|
||||
|
||||
async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
|
||||
"""Enable or disable login code delivery for an anonymous number.
|
||||
@@ -263,7 +232,7 @@ class FragmentClient:
|
||||
number: Phone number with or without leading ``+``.
|
||||
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
|
||||
"""
|
||||
return await toggle_login_codes(self, number, can_receive)
|
||||
return await self.anonymous_numbers.toggle_login_codes(number, can_receive)
|
||||
|
||||
async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
|
||||
"""Terminate all active Telegram sessions for an anonymous number.
|
||||
@@ -275,9 +244,9 @@ class FragmentClient:
|
||||
:class:`TerminateSessionsResult` with ``number`` and ``message``.
|
||||
|
||||
Raises:
|
||||
AnonymousNumberError: If the number is not owned by this account or has no active sessions.
|
||||
AnonymousNumberError: If the number is not owned or has no active sessions.
|
||||
"""
|
||||
return await terminate_sessions(self, number)
|
||||
return await self.anonymous_numbers.terminate_sessions(number)
|
||||
|
||||
async def search_usernames(
|
||||
self,
|
||||
@@ -289,19 +258,15 @@ class FragmentClient:
|
||||
"""Search the Fragment marketplace for Telegram usernames.
|
||||
|
||||
Args:
|
||||
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||
``""`` (available items). Omit to return all.
|
||||
offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id`
|
||||
from a previous result to fetch the next page.
|
||||
query: Search text. Omit or pass ``""`` to browse all.
|
||||
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
|
||||
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
|
||||
offset_id: Pass :attr:`UsernamesResult.next_offset_id` to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`UsernamesResult` with ``items`` (parsed list of item dicts)
|
||||
and ``next_offset_id`` (``None`` on the last page).
|
||||
:class:`UsernamesResult` with ``items`` and ``next_offset_id``.
|
||||
"""
|
||||
return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id)
|
||||
return await self.marketplace.search_usernames(query, sort=sort, filter=filter, offset_id=offset_id)
|
||||
|
||||
async def search_numbers(
|
||||
self,
|
||||
@@ -313,19 +278,15 @@ class FragmentClient:
|
||||
"""Search the Fragment marketplace for anonymous Telegram numbers.
|
||||
|
||||
Args:
|
||||
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||
``""`` (available items). Omit to return all.
|
||||
offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id`
|
||||
from a previous result to fetch the next page.
|
||||
query: Search text. Omit or pass ``""`` to browse all.
|
||||
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
|
||||
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
|
||||
offset_id: Pass :attr:`NumbersResult.next_offset_id` to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`NumbersResult` with ``items`` (parsed list of item dicts)
|
||||
and ``next_offset_id`` (``None`` on the last page).
|
||||
:class:`NumbersResult` with ``items`` and ``next_offset_id``.
|
||||
"""
|
||||
return await search_numbers(self, query, sort=sort, filter=filter, offset_id=offset_id)
|
||||
return await self.marketplace.search_numbers(query, sort=sort, filter=filter, offset_id=offset_id)
|
||||
|
||||
async def search_gifts(
|
||||
self,
|
||||
@@ -340,25 +301,19 @@ class FragmentClient:
|
||||
"""Search the Fragment gifts marketplace.
|
||||
|
||||
Args:
|
||||
query: Search text. Omit or pass ``""`` to browse without filtering by name.
|
||||
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
||||
``""`` (available items). Omit to return all.
|
||||
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
|
||||
attr: Attribute filters — mapping of trait name to accepted values, e.g.
|
||||
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
|
||||
Each key is sent as ``attr[Key]`` with its list of values.
|
||||
offset: Integer page offset from a previous :class:`GiftsResult`.
|
||||
Pass ``next_offset`` to fetch the next page.
|
||||
query: Search text. Omit or pass ``""`` to browse all.
|
||||
collection: Gift collection slug (e.g. ``"artisanbrick"``).
|
||||
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
|
||||
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
|
||||
view: Active attribute tab name (e.g. ``"Model"``).
|
||||
attr: Attribute filters — e.g. ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}``.
|
||||
offset: Pass :attr:`GiftsResult.next_offset` to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`GiftsResult` with ``items`` (parsed list of item dicts)
|
||||
and ``next_offset`` (``None`` on the last page).
|
||||
:class:`GiftsResult` with ``items`` and ``next_offset``.
|
||||
"""
|
||||
return await search_gifts(
|
||||
self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
|
||||
return await self.marketplace.search_gifts(
|
||||
query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
|
||||
)
|
||||
|
||||
async def call(
|
||||
@@ -366,27 +321,12 @@ class FragmentClient:
|
||||
) -> dict[str, Any]:
|
||||
"""Send a raw request to the Fragment API.
|
||||
|
||||
Useful for accessing undocumented or future Fragment API methods
|
||||
without waiting for a library update.
|
||||
|
||||
Args:
|
||||
method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``.
|
||||
data: Additional form-data fields to include in the request body.
|
||||
page_url: Fragment page URL used to derive the API hash and headers.
|
||||
Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``).
|
||||
data: Additional form-data fields.
|
||||
page_url: Fragment page URL to derive the API hash. Defaults to ``FRAGMENT_BASE_URL``.
|
||||
|
||||
Returns:
|
||||
Raw parsed JSON response as a dict.
|
||||
|
||||
Example::
|
||||
|
||||
result = await client.call(
|
||||
"searchPremiumGiftRecipient",
|
||||
{"query": "@username", "months": 3},
|
||||
page_url="https://fragment.com/premium/gift",
|
||||
)
|
||||
"""
|
||||
headers = make_headers(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, self.headers)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
FRAGMENT_DOMAIN: str = "fragment.com"
|
||||
FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}"
|
||||
|
||||
STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
|
||||
STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway"
|
||||
PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift"
|
||||
PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway"
|
||||
ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
|
||||
NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
|
||||
GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts"
|
||||
|
||||
DEFAULT_TIMEOUT: float = 30.0
|
||||
|
||||
# Fragment cookie keys required for authenticated API calls
|
||||
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
||||
|
||||
BASE_HEADERS: dict[str, str] = {
|
||||
"accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7",
|
||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"origin": FRAGMENT_BASE_URL,
|
||||
"priority": "u=1, i",
|
||||
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
|
||||
"sec-ch-ua-mobile": "?1",
|
||||
"sec-ch-ua-platform": '"Android"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"user-agent": (
|
||||
"Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36"
|
||||
),
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
}
|
||||
|
||||
# USDT-TON jetton master contract address on GRAM (ex TON) mainnet
|
||||
USDT_GRAM_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
|
||||
|
||||
# TON Connect device info sent during wallet connection handshake
|
||||
DEVICE_INFO: dict[str, Any] = {
|
||||
"platform": "iphone",
|
||||
"appName": "Tonkeeper",
|
||||
"appVersion": "26.05.0",
|
||||
"maxProtocolVersion": 2,
|
||||
"features": [
|
||||
"SendTransaction",
|
||||
{"name": "SendTransaction", "maxMessages": 255},
|
||||
{"name": "SignData", "types": ["text", "binary", "cell"]},
|
||||
],
|
||||
}
|
||||
|
||||
# Stars: direct purchase per transaction
|
||||
STARS_PURCHASE_MIN: int = 50
|
||||
STARS_PURCHASE_MAX: int = 10_000_000
|
||||
|
||||
# Stars: giveaway amount per winner
|
||||
STARS_GIVEAWAY_MIN: int = 500
|
||||
STARS_GIVEAWAY_MAX: int = 1_000_000
|
||||
|
||||
# Stars giveaway winner count
|
||||
STARS_WINNERS_MIN: int = 1
|
||||
STARS_WINNERS_MAX: int = 15
|
||||
|
||||
# Premium giveaway winner count
|
||||
PREMIUM_WINNERS_MIN: int = 1
|
||||
PREMIUM_WINNERS_MAX: int = 24_000
|
||||
|
||||
# GRAM (ex TON) topup / Ads recharge amount
|
||||
GRAM_TOPUP_MIN: int = 1
|
||||
GRAM_TOPUP_MAX: int = 1_000_000_000
|
||||
|
||||
# Minimum wallet balances required before broadcasting a transaction
|
||||
MIN_GRAM_BALANCE: float = 0.33
|
||||
MIN_USDT_BALANCE: float = 0.75
|
||||
|
||||
# Premium subscription durations (months)
|
||||
PREMIUM_MONTHS_VALID: frozenset[int] = frozenset({3, 6, 12})
|
||||
|
||||
# Mnemonic phrase valid word counts
|
||||
MNEMONIC_WORD_COUNTS_VALID: frozenset[int] = frozenset({12, 24})
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import re
|
||||
from typing import Any, cast
|
||||
|
||||
from curl_cffi.requests import AsyncSession, Response
|
||||
|
||||
from pyfragment.core.constants import FRAGMENT_BASE_URL
|
||||
from pyfragment.exceptions import FragmentPageError, ParseError
|
||||
|
||||
|
||||
async def get_fragment_hash(
|
||||
session: AsyncSession[Any],
|
||||
headers: dict[str, str],
|
||||
page_url: str,
|
||||
) -> str:
|
||||
# Derive the natural referer: strip the last path segment (e.g. /stars/buy → /stars)
|
||||
parent_url = page_url.rsplit("/", 1)[0] or FRAGMENT_BASE_URL
|
||||
|
||||
page_headers = {k: v for k, v in headers.items() if k not in ("content-type", "origin")}
|
||||
page_headers["referer"] = parent_url
|
||||
page_headers["x-aj-referer"] = parent_url
|
||||
page_headers.pop("x-aj-referer", None)
|
||||
page_headers.pop("x-requested-with", None)
|
||||
|
||||
response = await session.get(page_url, headers=page_headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
|
||||
|
||||
match = re.search(r"(?:https://fragment\.com)?\\\\?/api\?hash=([a-f0-9]+)", response.text)
|
||||
if not match:
|
||||
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
|
||||
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def parse_json_response(response: Response, context: str) -> dict[str, Any]:
|
||||
try:
|
||||
return cast(dict[str, Any], response.json()) # type: ignore[no-untyped-call]
|
||||
except Exception as exc:
|
||||
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
||||
|
||||
|
||||
async def fragment_request(
|
||||
session: AsyncSession[Any],
|
||||
fragment_hash: str,
|
||||
headers: dict[str, str],
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
for attempt in range(3):
|
||||
resp = await session.post(
|
||||
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
|
||||
headers=headers,
|
||||
data=data,
|
||||
)
|
||||
if resp.status_code == 429 and attempt < 2:
|
||||
await asyncio.sleep(1 + attempt + random.uniform(0, 0.5))
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
raise FragmentPageError(
|
||||
FragmentPageError.BAD_STATUS.format(status=resp.status_code, url=f"{FRAGMENT_BASE_URL}/api")
|
||||
)
|
||||
return parse_json_response(resp, data.get("method", "request"))
|
||||
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID, REQUIRED_COOKIE_KEYS
|
||||
from pyfragment.enums import ApiProvider, WalletVersion
|
||||
from pyfragment.exceptions import ConfigurationError, CookieError
|
||||
|
||||
|
||||
def parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]:
|
||||
if isinstance(cookies, str):
|
||||
try:
|
||||
cookies = json.loads(cookies)
|
||||
except Exception as exc:
|
||||
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
|
||||
return cast(dict[str, Any], cookies)
|
||||
|
||||
|
||||
def validate_cookie_keys(cookies: dict[str, Any]) -> None:
|
||||
missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
|
||||
if missing:
|
||||
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing)))
|
||||
|
||||
|
||||
def normalize_provider(api_provider: str) -> ApiProvider:
|
||||
try:
|
||||
return ApiProvider(api_provider.strip().lower())
|
||||
except ValueError:
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.UNSUPPORTED_PROVIDER.format(
|
||||
provider=api_provider,
|
||||
supported=", ".join(sorted(p.value for p in ApiProvider)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def normalize_wallet_version(wallet_version: str) -> WalletVersion:
|
||||
version = wallet_version.strip().upper()
|
||||
try:
|
||||
return WalletVersion(version)
|
||||
except ValueError:
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.UNSUPPORTED_VERSION.format(
|
||||
version=version,
|
||||
supported=", ".join(sorted(m.value for m in WalletVersion)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def validate_credentials(seed: str, api_key: str) -> None:
|
||||
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
|
||||
if missing:
|
||||
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
|
||||
|
||||
word_count = len(seed.split())
|
||||
if word_count not in MNEMONIC_WORD_COUNTS_VALID:
|
||||
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain-level helpers for Fragment operations."""
|
||||
@@ -0,0 +1,5 @@
|
||||
from pyfragment.domains.ads.recharge import recharge_ads
|
||||
from pyfragment.domains.ads.service import AdsService
|
||||
from pyfragment.domains.ads.tonup import topup_gram
|
||||
|
||||
__all__ = ["AdsService", "recharge_ads", "topup_gram"]
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdsTopupResult:
|
||||
transaction_id: str
|
||||
username: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AdsTopupResult(username='{self.username}', amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdsRechargeResult:
|
||||
transaction_id: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AdsRechargeResult(amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')"
|
||||
@@ -1,42 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
AdsRechargeResult,
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
UnexpectedError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
||||
from pyfragment.utils import get_account_info, process_transaction
|
||||
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
|
||||
from pyfragment.domains.ads.models import AdsRechargeResult
|
||||
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
|
||||
from pyfragment.services.tonapi.account import get_account_info
|
||||
from pyfragment.services.tonapi.transaction import process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
|
||||
"""Add funds to your own Telegram Ads account.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
account: Your Fragment Ads account identifier — the channel or bot username
|
||||
the Ads account is linked to (e.g. ``"@mychannel"``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``amount`` is not a valid integer in the allowed range.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
||||
if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT)
|
||||
|
||||
try:
|
||||
await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
|
||||
@@ -51,7 +34,7 @@ async def recharge_ads(client: FragmentClient, account: str, amount: int) -> Ads
|
||||
"getAdsRechargeLink",
|
||||
{
|
||||
"account": json.dumps(account_info),
|
||||
"device": DEVICE,
|
||||
"device": json.dumps(DEVICE_INFO),
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
},
|
||||
@@ -63,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 GRAM (ex TON): %s", account, amount, exc, exc_info=True)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to recharge Ads account '%s' for %s GRAM (ex TON) due to an unexpected error", account, amount)
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult
|
||||
from pyfragment.domains.ads.recharge import recharge_ads
|
||||
from pyfragment.domains.ads.tonup import topup_gram
|
||||
from pyfragment.domains.base import BaseService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class AdsService(BaseService):
|
||||
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
|
||||
return await recharge_ads(self._client, account, amount)
|
||||
|
||||
async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||
return await topup_gram(self._client, username, amount, show_sender=show_sender)
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
AdsTopupResult,
|
||||
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
|
||||
from pyfragment.domains.ads.models import AdsTopupResult
|
||||
from pyfragment.domains.payments import parse_required_payment_amount
|
||||
from pyfragment.exceptions import (
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
@@ -12,33 +15,19 @@ from pyfragment.types import (
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
||||
from pyfragment.utils import get_account_info, process_transaction
|
||||
from pyfragment.services.tonapi.account import get_account_info
|
||||
from pyfragment.services.tonapi.transaction import process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||
"""Top up TON to a recipient's Telegram balance.
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
username: Recipient's Telegram username (with or without ``@``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000.
|
||||
UserNotFoundError: If the recipient is not found on Telegram.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
||||
async def topup_gram(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||
if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT)
|
||||
|
||||
try:
|
||||
await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
|
||||
@@ -49,16 +38,17 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||
|
||||
result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE)
|
||||
required_payment_amount = parse_required_payment_amount(result)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="GRAM (ex TON) topup"))
|
||||
|
||||
account = await get_account_info(client)
|
||||
transaction = await client.call(
|
||||
"getAdsTopupLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"device": json.dumps(DEVICE_INFO),
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
@@ -68,10 +58,16 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
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 GRAM (ex TON) for user '%s' with %s GRAM (ex TON): %s", username, amount, exc, exc_info=True
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Failed to top up GRAM (ex TON) for user '%s' with %s GRAM (ex TON) due to an unexpected error", username, amount
|
||||
)
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,12 @@
|
||||
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
|
||||
from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes
|
||||
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
|
||||
|
||||
__all__ = [
|
||||
"AnonymousNumbersService",
|
||||
"LoginCodeResult",
|
||||
"TerminateSessionsResult",
|
||||
"get_login_code",
|
||||
"terminate_sessions",
|
||||
"toggle_login_codes",
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginCodeResult:
|
||||
number: str
|
||||
code: str | None
|
||||
active_sessions: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
code_str = f"'{self.code}'" if self.code else "None"
|
||||
return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminateSessionsResult:
|
||||
number: str
|
||||
message: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})"
|
||||
+27
-56
@@ -1,42 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
AnonymousNumberError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
LoginCodeResult,
|
||||
TerminateSessionsResult,
|
||||
UnexpectedError,
|
||||
)
|
||||
from pyfragment.types.constants import NUMBERS_PAGE
|
||||
from pyfragment.utils import parse_login_code
|
||||
from pyfragment.core.constants import NUMBERS_PAGE
|
||||
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
|
||||
from pyfragment.domains.anonymous_numbers.parser import parse_login_code
|
||||
from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult:
|
||||
"""Fetch the current pending login code for an anonymous number.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
|
||||
|
||||
Returns:
|
||||
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if no pending code),
|
||||
and ``active_sessions`` count.
|
||||
|
||||
Raises:
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
try:
|
||||
clean = _strip_plus(number)
|
||||
result = await client.call(
|
||||
@@ -52,24 +36,15 @@ 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
|
||||
|
||||
|
||||
async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None:
|
||||
"""Enable or disable login code delivery for an anonymous number.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
number: Phone number with or without leading ``+``.
|
||||
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
|
||||
|
||||
Raises:
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
try:
|
||||
clean = _strip_plus(number)
|
||||
result = await client.call(
|
||||
@@ -81,31 +56,25 @@ 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
|
||||
|
||||
|
||||
async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult:
|
||||
"""Terminate all active Telegram sessions for an anonymous number.
|
||||
|
||||
This is a two-step operation: Fragment first returns a confirmation hash,
|
||||
which is then submitted to confirm the termination.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
number: Phone number with or without leading ``+``.
|
||||
|
||||
Returns:
|
||||
:class:`TerminateSessionsResult` with ``number`` and ``message``.
|
||||
|
||||
Raises:
|
||||
AnonymousNumberError: If the number is not owned by this account or has no active sessions,
|
||||
or if Fragment returns an error during termination.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
try:
|
||||
clean = _strip_plus(number)
|
||||
|
||||
@@ -137,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
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
|
||||
ROW_RE = re.compile(r"<tr[\s>]")
|
||||
|
||||
|
||||
def parse_login_code(html: str) -> tuple[str | None, int]:
|
||||
match = CODE_RE.search(html)
|
||||
code = match.group(1).strip() if match else None
|
||||
active_sessions = len(ROW_RE.findall(html))
|
||||
return code, active_sessions
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
|
||||
from pyfragment.domains.anonymous_numbers.number import get_login_code, terminate_sessions, toggle_login_codes
|
||||
from pyfragment.domains.base import BaseService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class AnonymousNumbersService(BaseService):
|
||||
async def get_login_code(self, number: str) -> LoginCodeResult:
|
||||
return await get_login_code(self._client, number)
|
||||
|
||||
async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
|
||||
return await toggle_login_codes(self._client, number, can_receive)
|
||||
|
||||
async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
|
||||
return await terminate_sessions(self._client, number)
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from curl_cffi.requests import AsyncSession
|
||||
|
||||
from pyfragment.core.constants import BASE_HEADERS
|
||||
from pyfragment.core.transport import fragment_request, get_fragment_hash
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def raw_api_call(
|
||||
cookies: dict[str, Any],
|
||||
timeout: float,
|
||||
method: str,
|
||||
data: dict[str, Any] | None,
|
||||
page_url: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
base = headers if headers is not None else BASE_HEADERS
|
||||
payload = {"method": method, **(data or {})}
|
||||
call_headers = {**base, "referer": page_url, "x-aj-referer": page_url}
|
||||
logger.debug("Starting Fragment API call '%s' on %s", method, page_url)
|
||||
try:
|
||||
async with AsyncSession(cookies=cookies, timeout=timeout, impersonate="chrome") as session:
|
||||
fragment_hash = await get_fragment_hash(session, call_headers, page_url)
|
||||
response = await fragment_request(session, fragment_hash, call_headers, payload)
|
||||
logger.debug("Completed Fragment API call '%s' with response keys: %s", method, sorted(response.keys()))
|
||||
return response
|
||||
except Exception:
|
||||
logger.exception("Failed to call Fragment API method '%s' on %s", method, page_url)
|
||||
raise
|
||||
|
||||
|
||||
class BaseService:
|
||||
def __init__(self, client: FragmentClient) -> None:
|
||||
self._client = client
|
||||
@@ -0,0 +1,11 @@
|
||||
from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
|
||||
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
|
||||
from pyfragment.domains.giveaways.service import GiveawaysService
|
||||
|
||||
__all__ = [
|
||||
"GiveawaysService",
|
||||
"PremiumGiveawayResult",
|
||||
"StarsGiveawayResult",
|
||||
"giveaway_premium",
|
||||
"giveaway_stars",
|
||||
]
|
||||
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.core.constants import (
|
||||
DEVICE_INFO,
|
||||
PREMIUM_GIVEAWAY_PAGE,
|
||||
PREMIUM_MONTHS_VALID,
|
||||
PREMIUM_WINNERS_MAX,
|
||||
PREMIUM_WINNERS_MIN,
|
||||
STARS_GIVEAWAY_MAX,
|
||||
STARS_GIVEAWAY_MIN,
|
||||
STARS_GIVEAWAY_PAGE,
|
||||
STARS_WINNERS_MAX,
|
||||
STARS_WINNERS_MIN,
|
||||
)
|
||||
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
|
||||
from pyfragment.domains.payments import parse_required_payment_amount
|
||||
from pyfragment.enums import PaymentMethod
|
||||
from pyfragment.exceptions import (
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.services.tonapi.account import get_account_info
|
||||
from pyfragment.services.tonapi.transaction import process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _state_nonce() -> str:
|
||||
# Fragment expects a pseudo-random nonce-like dh value in giveaway state updates.
|
||||
return str(random.randint(100_000_000, 2_147_483_647))
|
||||
|
||||
|
||||
async def giveaway_stars(
|
||||
client: FragmentClient,
|
||||
channel: str,
|
||||
winners: int,
|
||||
amount: int,
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> StarsGiveawayResult:
|
||||
if not isinstance(winners, int) or not (STARS_WINNERS_MIN <= winners <= STARS_WINNERS_MAX):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
|
||||
if not isinstance(amount, int) or not (STARS_GIVEAWAY_MIN <= amount <= STARS_GIVEAWAY_MAX):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
|
||||
if not any(payment_method == m for m in PaymentMethod):
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||
method=payment_method,
|
||||
supported=", ".join(sorted(m.value for m in PaymentMethod)),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||
|
||||
await client.call(
|
||||
"updateStarsGiveawayState",
|
||||
{"mode": "new", "lv": "false", "dh": _state_nonce()},
|
||||
page_url=STARS_GIVEAWAY_PAGE,
|
||||
)
|
||||
await client.call(
|
||||
"updateStarsGiveawayPrices",
|
||||
{"quantity": winners, "stars": amount},
|
||||
page_url=STARS_GIVEAWAY_PAGE,
|
||||
)
|
||||
|
||||
result = await client.call(
|
||||
"initGiveawayStarsRequest",
|
||||
{
|
||||
"recipient": recipient,
|
||||
"quantity": str(winners),
|
||||
"stars": str(amount),
|
||||
"payment_method": payment_method,
|
||||
},
|
||||
page_url=STARS_GIVEAWAY_PAGE,
|
||||
)
|
||||
required_payment_amount = parse_required_payment_amount(result)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
|
||||
|
||||
account = await get_account_info(client)
|
||||
transaction = await client.call(
|
||||
"getGiveawayStarsLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": json.dumps(DEVICE_INFO),
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
},
|
||||
page_url=STARS_GIVEAWAY_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(
|
||||
client,
|
||||
transaction,
|
||||
payment_method=payment_method,
|
||||
required_payment_amount=required_payment_amount,
|
||||
)
|
||||
return StarsGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=amount)
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def giveaway_premium(
|
||||
client: FragmentClient,
|
||||
channel: str,
|
||||
winners: int,
|
||||
months: int = 3,
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> PremiumGiveawayResult:
|
||||
if not isinstance(winners, int) or not (PREMIUM_WINNERS_MIN <= winners <= PREMIUM_WINNERS_MAX):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
|
||||
if months not in PREMIUM_MONTHS_VALID:
|
||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||
if not any(payment_method == m for m in PaymentMethod):
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||
method=payment_method,
|
||||
supported=", ".join(sorted(m.value for m in PaymentMethod)),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await client.call(
|
||||
"searchPremiumGiveawayRecipient",
|
||||
{"query": channel, "quantity": winners, "months": months},
|
||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||
)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||
|
||||
await client.call(
|
||||
"updatePremiumGiveawayState",
|
||||
{
|
||||
"mode": "new",
|
||||
"lv": "false",
|
||||
"dh": _state_nonce(),
|
||||
"quantity": "",
|
||||
},
|
||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||
)
|
||||
await client.call(
|
||||
"updatePremiumGiveawayPrices",
|
||||
{"quantity": winners},
|
||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||
)
|
||||
|
||||
result = await client.call(
|
||||
"initGiveawayPremiumRequest",
|
||||
{
|
||||
"recipient": recipient,
|
||||
"quantity": str(winners),
|
||||
"months": str(months),
|
||||
"payment_method": payment_method,
|
||||
},
|
||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||
)
|
||||
required_payment_amount = parse_required_payment_amount(result)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
|
||||
|
||||
account = await get_account_info(client)
|
||||
transaction = await client.call(
|
||||
"getGiveawayPremiumLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": json.dumps(DEVICE_INFO),
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
},
|
||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(
|
||||
client,
|
||||
transaction,
|
||||
payment_method=payment_method,
|
||||
required_payment_amount=required_payment_amount,
|
||||
)
|
||||
return PremiumGiveawayResult(transaction_id=tx_hash, channel=channel, winners=winners, amount=months)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class StarsGiveawayResult:
|
||||
transaction_id: str
|
||||
channel: str
|
||||
winners: int
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"StarsGiveawayResult(channel='{self.channel}', winners={self.winners}, "
|
||||
f"amount={self.amount} stars per winner, tx='{self.transaction_id}')"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PremiumGiveawayResult:
|
||||
transaction_id: str
|
||||
channel: str
|
||||
winners: int
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, "
|
||||
f"amount={self.amount} months per winner, tx='{self.transaction_id}')"
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.domains.base import BaseService
|
||||
from pyfragment.domains.giveaways.giveaway import giveaway_premium, giveaway_stars
|
||||
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
|
||||
from pyfragment.enums import PaymentMethod
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class GiveawaysService(BaseService):
|
||||
async def giveaway_stars(
|
||||
self,
|
||||
channel: str,
|
||||
winners: int,
|
||||
amount: int,
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> StarsGiveawayResult:
|
||||
return await giveaway_stars(self._client, channel, winners, amount, payment_method=payment_method)
|
||||
|
||||
async def giveaway_premium(
|
||||
self,
|
||||
channel: str,
|
||||
winners: int,
|
||||
months: int = 3,
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> PremiumGiveawayResult:
|
||||
return await giveaway_premium(self._client, channel, winners, months, payment_method=payment_method)
|
||||
@@ -0,0 +1,13 @@
|
||||
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
|
||||
from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
|
||||
from pyfragment.domains.marketplace.service import MarketplaceService
|
||||
|
||||
__all__ = [
|
||||
"GiftsResult",
|
||||
"MarketplaceService",
|
||||
"NumbersResult",
|
||||
"UsernamesResult",
|
||||
"search_gifts",
|
||||
"search_numbers",
|
||||
"search_usernames",
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsernamesResult:
|
||||
items: list[dict[str, Any]]
|
||||
next_offset_id: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"UsernamesResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumbersResult:
|
||||
items: list[dict[str, Any]]
|
||||
next_offset_id: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"NumbersResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GiftsResult:
|
||||
items: list[dict[str, Any]]
|
||||
next_offset: int | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})"
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
ROW_BLOCK_RE = re.compile(r'<tr\b[^>]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)</tr>', re.DOTALL)
|
||||
HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"')
|
||||
VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<')
|
||||
PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<")
|
||||
DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>')
|
||||
DATETIME_SHORT_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>')
|
||||
NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$")
|
||||
|
||||
GRID_ITEM_RE = re.compile(r'<a\b[^>]*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)</a>', re.DOTALL)
|
||||
GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))')
|
||||
GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<')
|
||||
GRID_NUM_RE = re.compile(r'class="item-num">[^#]*#(\w+)<')
|
||||
GRID_PRICE_RE = re.compile(r'class="[^"]*tm-grid-item-value[^"]*icon-ton[^"]*"[^>]*>\s*([0-9][^<]*?)\s*<')
|
||||
GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<]+?)\s*<')
|
||||
GRID_DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"')
|
||||
|
||||
|
||||
def parse_auction_rows(html: str) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for row_match in ROW_BLOCK_RE.finditer(html):
|
||||
row = row_match.group(1)
|
||||
|
||||
href_m = HREF_RE.search(row)
|
||||
if not href_m:
|
||||
continue
|
||||
slug = href_m.group(1).lstrip("/")
|
||||
|
||||
values = [m.group(1).strip() for m in VALUE_RE.finditer(row)]
|
||||
name = values[0] if values else slug
|
||||
|
||||
status: str | None = None
|
||||
for v in values[1:]:
|
||||
if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v):
|
||||
status = v
|
||||
break
|
||||
|
||||
price_m = PRICE_RE.search(row)
|
||||
price: str | None = None
|
||||
if price_m:
|
||||
raw_price = price_m.group(1).strip().replace(",", "")
|
||||
try:
|
||||
price = f"{float(raw_price):.2f}"
|
||||
except ValueError:
|
||||
price = raw_price
|
||||
|
||||
time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row)
|
||||
date: str | None = time_m.group(1) if time_m else None
|
||||
|
||||
items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date})
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for item_match in GRID_ITEM_RE.finditer(html):
|
||||
block = item_match.group(0)
|
||||
|
||||
href_m = GRID_HREF_RE.search(block)
|
||||
if not href_m:
|
||||
continue
|
||||
slug = href_m.group(1).lstrip("/")
|
||||
|
||||
name_m = GRID_NAME_RE.search(block)
|
||||
num_m = GRID_NUM_RE.search(block)
|
||||
item_name = name_m.group(1).strip() if name_m else slug
|
||||
item_num = f" #{num_m.group(1)}" if num_m else ""
|
||||
name = f"{item_name}{item_num}"
|
||||
|
||||
status_m = GRID_STATUS_RE.search(block)
|
||||
status: str | None = status_m.group(1).strip() if status_m else None
|
||||
|
||||
price_m = GRID_PRICE_RE.search(block)
|
||||
price: str | None = None
|
||||
if price_m:
|
||||
raw_price = price_m.group(1).strip().replace(",", "")
|
||||
try:
|
||||
price = f"{float(raw_price):.2f}"
|
||||
except ValueError:
|
||||
price = raw_price
|
||||
|
||||
time_m = GRID_DATETIME_RE.search(block)
|
||||
date: str | None = time_m.group(1) if time_m else None
|
||||
|
||||
items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date})
|
||||
|
||||
next_offset_m = re.search(r'data-next-offset="(\d+)"', html)
|
||||
next_offset = int(next_offset_m.group(1)) if next_offset_m else None
|
||||
|
||||
return items, next_offset
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyfragment.core.constants import FRAGMENT_BASE_URL, GIFTS_PAGE, NUMBERS_PAGE
|
||||
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
|
||||
from pyfragment.domains.marketplace.parser import parse_auction_rows, parse_gift_items
|
||||
from pyfragment.exceptions import FragmentAPIError, FragmentError, UnexpectedError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def search_usernames(
|
||||
client: FragmentClient,
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> UsernamesResult:
|
||||
data: dict[str, Any] = {"type": "usernames", "query": query}
|
||||
if sort is not None:
|
||||
data["sort"] = sort
|
||||
if filter is not None:
|
||||
data["filter"] = filter
|
||||
if offset_id is not None:
|
||||
data["offset_id"] = offset_id
|
||||
|
||||
try:
|
||||
result = await client.call("searchAuctions", data, page_url=FRAGMENT_BASE_URL)
|
||||
if result.get("error"):
|
||||
raise FragmentAPIError(result["error"])
|
||||
|
||||
items = parse_auction_rows(result.get("html") or "")
|
||||
raw_noi = result.get("next_offset_id")
|
||||
next_offset_id = str(raw_noi) if raw_noi else None
|
||||
return UsernamesResult(items=items, next_offset_id=next_offset_id)
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def search_numbers(
|
||||
client: FragmentClient,
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> NumbersResult:
|
||||
data: dict[str, Any] = {"type": "numbers", "query": query}
|
||||
if sort is not None:
|
||||
data["sort"] = sort
|
||||
if filter is not None:
|
||||
data["filter"] = filter
|
||||
if offset_id is not None:
|
||||
data["offset_id"] = offset_id
|
||||
|
||||
try:
|
||||
result = await client.call("searchAuctions", data, page_url=NUMBERS_PAGE)
|
||||
if result.get("error"):
|
||||
raise FragmentAPIError(result["error"])
|
||||
|
||||
items = parse_auction_rows(result.get("html") or "")
|
||||
raw_noi = result.get("next_offset_id")
|
||||
next_offset_id = str(raw_noi) if raw_noi else None
|
||||
return NumbersResult(items=items, next_offset_id=next_offset_id)
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def search_gifts(
|
||||
client: FragmentClient,
|
||||
query: str = "",
|
||||
collection: str | None = None,
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
view: str | None = None,
|
||||
attr: dict[str, list[str]] | None = None,
|
||||
offset: int | None = None,
|
||||
) -> GiftsResult:
|
||||
data: dict[str, Any] = {"type": "gifts", "query": query}
|
||||
if collection is not None:
|
||||
data["collection"] = collection
|
||||
if sort is not None:
|
||||
data["sort"] = sort
|
||||
if filter is not None:
|
||||
data["filter"] = filter
|
||||
if view is not None:
|
||||
data["view"] = view
|
||||
if attr is not None:
|
||||
for trait, values in attr.items():
|
||||
data[f"attr[{trait}]"] = values
|
||||
if offset is not None:
|
||||
data["offset"] = offset
|
||||
|
||||
try:
|
||||
result = await client.call("searchAuctions", data, page_url=GIFTS_PAGE)
|
||||
if result.get("error"):
|
||||
raise FragmentAPIError(result["error"])
|
||||
|
||||
items, next_offset = parse_gift_items(result.get("html") or "")
|
||||
return GiftsResult(items=items, next_offset=next_offset)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.domains.base import BaseService
|
||||
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
|
||||
from pyfragment.domains.marketplace.search import search_gifts, search_numbers, search_usernames
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class MarketplaceService(BaseService):
|
||||
async def search_usernames(
|
||||
self,
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> UsernamesResult:
|
||||
return await search_usernames(self._client, query, sort=sort, filter=filter, offset_id=offset_id)
|
||||
|
||||
async def search_numbers(
|
||||
self,
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> NumbersResult:
|
||||
return await search_numbers(self._client, query, sort=sort, filter=filter, offset_id=offset_id)
|
||||
|
||||
async def search_gifts(
|
||||
self,
|
||||
query: str = "",
|
||||
collection: str | None = None,
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
view: str | None = None,
|
||||
attr: dict[str, list[str]] | None = None,
|
||||
offset: int | None = None,
|
||||
) -> GiftsResult:
|
||||
return await search_gifts(
|
||||
self._client, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_required_payment_amount(init_response: dict[str, Any]) -> float | None:
|
||||
raw_amount = init_response.get("amount")
|
||||
try:
|
||||
return float(str(raw_amount))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,5 @@
|
||||
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
|
||||
from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars
|
||||
from pyfragment.domains.purchases.service import PurchasesService
|
||||
|
||||
__all__ = ["PremiumResult", "PurchasesService", "StarsResult", "purchase_premium", "purchase_stars"]
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PremiumResult:
|
||||
transaction_id: str
|
||||
username: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StarsResult:
|
||||
transaction_id: str
|
||||
username: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')"
|
||||
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.core.constants import (
|
||||
DEVICE_INFO,
|
||||
PREMIUM_MONTHS_VALID,
|
||||
PREMIUM_PAGE,
|
||||
STARS_PAGE,
|
||||
STARS_PURCHASE_MAX,
|
||||
STARS_PURCHASE_MIN,
|
||||
)
|
||||
from pyfragment.domains.payments import parse_required_payment_amount
|
||||
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
|
||||
from pyfragment.enums import PaymentMethod
|
||||
from pyfragment.exceptions import (
|
||||
AlreadySubscribedError,
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.services.tonapi.account import get_account_info
|
||||
from pyfragment.services.tonapi.transaction import process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _state_nonce() -> str:
|
||||
# Fragment accepts a pseudo-random request nonce in state update methods.
|
||||
return str(random.randint(100_000_000, 2_147_483_647))
|
||||
|
||||
|
||||
async def purchase_stars(
|
||||
client: FragmentClient,
|
||||
username: str,
|
||||
amount: int,
|
||||
show_sender: bool = True,
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> StarsResult:
|
||||
if not isinstance(amount, int) or not (STARS_PURCHASE_MIN <= amount <= STARS_PURCHASE_MAX):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
|
||||
if not any(payment_method == m for m in PaymentMethod):
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||
method=payment_method,
|
||||
supported=", ".join(sorted(m.value for m in PaymentMethod)),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
|
||||
if "assigned to a user" in str(result.get("error", "")).lower():
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_A_USER.format(username=username))
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||
|
||||
await client.call(
|
||||
"updateStarsBuyState",
|
||||
{"mode": "new", "lv": "false", "dh": _state_nonce()},
|
||||
page_url=STARS_PAGE,
|
||||
)
|
||||
result = await client.call(
|
||||
"initBuyStarsRequest",
|
||||
{"recipient": recipient, "quantity": amount, "payment_method": payment_method},
|
||||
page_url=STARS_PAGE,
|
||||
)
|
||||
required_payment_amount = parse_required_payment_amount(result)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
|
||||
|
||||
account = await get_account_info(client)
|
||||
transaction = await client.call(
|
||||
"getBuyStarsLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": json.dumps(DEVICE_INFO),
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
},
|
||||
page_url=STARS_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(
|
||||
client,
|
||||
transaction,
|
||||
payment_method=payment_method,
|
||||
required_payment_amount=required_payment_amount,
|
||||
)
|
||||
return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def purchase_premium(
|
||||
client: FragmentClient,
|
||||
username: str,
|
||||
months: int,
|
||||
show_sender: bool = True,
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> PremiumResult:
|
||||
if months not in PREMIUM_MONTHS_VALID:
|
||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||
if not any(payment_method == m for m in PaymentMethod):
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||
method=payment_method,
|
||||
supported=", ".join(sorted(m.value for m in PaymentMethod)),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
|
||||
if "assigned to a user" in str(result.get("error", "")).lower():
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_A_USER.format(username=username))
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||
|
||||
await client.call(
|
||||
"updatePremiumState",
|
||||
{"mode": "new", "lv": "false", "dh": _state_nonce()},
|
||||
page_url=PREMIUM_PAGE,
|
||||
)
|
||||
result = await client.call(
|
||||
"initGiftPremiumRequest",
|
||||
{"recipient": recipient, "months": months, "payment_method": payment_method},
|
||||
page_url=PREMIUM_PAGE,
|
||||
)
|
||||
error_text = str(result.get("error", "")).strip().lower()
|
||||
if "already subscribed to telegram premium" in error_text:
|
||||
raise AlreadySubscribedError(AlreadySubscribedError.PREMIUM_ACTIVE)
|
||||
required_payment_amount = parse_required_payment_amount(result)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
|
||||
|
||||
account = await get_account_info(client)
|
||||
transaction = await client.call(
|
||||
"getGiftPremiumLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": json.dumps(DEVICE_INFO),
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
},
|
||||
page_url=PREMIUM_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(
|
||||
client,
|
||||
transaction,
|
||||
payment_method=payment_method,
|
||||
required_payment_amount=required_payment_amount,
|
||||
)
|
||||
return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.domains.base import BaseService
|
||||
from pyfragment.domains.purchases.models import PremiumResult, StarsResult
|
||||
from pyfragment.domains.purchases.purchase import purchase_premium, purchase_stars
|
||||
from pyfragment.enums import PaymentMethod
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class PurchasesService(BaseService):
|
||||
async def purchase_stars(
|
||||
self,
|
||||
username: str,
|
||||
amount: int,
|
||||
show_sender: bool = True,
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> StarsResult:
|
||||
return await purchase_stars(self._client, username, amount, show_sender=show_sender, payment_method=payment_method)
|
||||
|
||||
async def purchase_premium(
|
||||
self,
|
||||
username: str,
|
||||
months: int,
|
||||
show_sender: bool = True,
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
) -> PremiumResult:
|
||||
return await purchase_premium(self._client, username, months, show_sender=show_sender, payment_method=payment_method)
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from tonutils.contracts.wallet import WalletHighloadV2, WalletHighloadV3R1, WalletV4R2, WalletV5R1
|
||||
|
||||
|
||||
class PaymentMethod(StrEnum):
|
||||
GRAM = "ton"
|
||||
USDT_GRAM = "usdt_ton"
|
||||
|
||||
# Not supported yet
|
||||
USDT_ETH = "usdt_eth"
|
||||
USDT_POL = "usdt_pol"
|
||||
USDC_ETH = "usdc_eth"
|
||||
USDC_BASE = "usdc_base"
|
||||
USDC_POL = "usdc_pol"
|
||||
|
||||
|
||||
class WalletVersion(StrEnum):
|
||||
V4R2 = "V4R2"
|
||||
V5R1 = "V5R1"
|
||||
HighloadV2 = "HighloadV2"
|
||||
HighloadV3R1 = "HighloadV3R1"
|
||||
|
||||
|
||||
WALLET_CLASSES: dict[WalletVersion, Any] = {
|
||||
WalletVersion.V4R2: WalletV4R2,
|
||||
WalletVersion.V5R1: WalletV5R1,
|
||||
WalletVersion.HighloadV2: WalletHighloadV2,
|
||||
WalletVersion.HighloadV3R1: WalletHighloadV3R1,
|
||||
}
|
||||
|
||||
|
||||
class ApiProvider(StrEnum):
|
||||
TONAPI = "tonapi" # tonconsole.com — default
|
||||
TONCENTER = "toncenter" # t.me/toncenter
|
||||
|
||||
|
||||
class SupportedBrowser(StrEnum):
|
||||
ARC = "arc"
|
||||
BRAVE = "brave"
|
||||
CHROME = "chrome"
|
||||
CHROMIUM = "chromium"
|
||||
CHROMIUM_BASED = "chromium_based"
|
||||
EDGE = "edge"
|
||||
FIREFOX = "firefox"
|
||||
FIREFOX_BASED = "firefox_based"
|
||||
LIBREWOLF = "librewolf"
|
||||
OPERA = "opera"
|
||||
OPERA_GX = "opera_gx"
|
||||
SAFARI = "safari"
|
||||
VIVALDI = "vivaldi"
|
||||
@@ -1,51 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pyfragment.core.constants import (
|
||||
GRAM_TOPUP_MAX,
|
||||
GRAM_TOPUP_MIN,
|
||||
MNEMONIC_WORD_COUNTS_VALID,
|
||||
PREMIUM_MONTHS_VALID,
|
||||
PREMIUM_WINNERS_MAX,
|
||||
PREMIUM_WINNERS_MIN,
|
||||
STARS_GIVEAWAY_MAX,
|
||||
STARS_GIVEAWAY_MIN,
|
||||
STARS_PURCHASE_MAX,
|
||||
STARS_PURCHASE_MIN,
|
||||
STARS_WINNERS_MAX,
|
||||
STARS_WINNERS_MIN,
|
||||
)
|
||||
|
||||
|
||||
class FragmentError(Exception):
|
||||
"""Base exception for all pyfragment library errors."""
|
||||
"""Base exception for all pyfragment errors."""
|
||||
|
||||
|
||||
class ClientError(FragmentError):
|
||||
"""Raised for client configuration and setup issues (bad params, invalid cookies)."""
|
||||
"""Raised for client configuration and setup issues."""
|
||||
|
||||
|
||||
class ConfigurationError(ClientError):
|
||||
"""Raised when required client parameters are missing or invalid."""
|
||||
|
||||
MISSING_VARS = "Missing required parameter(s): {keys}."
|
||||
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
|
||||
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
|
||||
INVALID_API_KEY = (
|
||||
"Invalid Tonapi API key: expected at least 68 characters, got {length}. Generate a key at https://tonconsole.com."
|
||||
UNSUPPORTED_VERSION = "Unsupported wallet version '{version}'. Supported values: {supported}."
|
||||
INVALID_MNEMONIC = f"Invalid mnemonic phrase: expected {', '.join(str(n) for n in sorted(MNEMONIC_WORD_COUNTS_VALID))} words, got {{count}}."
|
||||
UNSUPPORTED_PROVIDER = "Unsupported API provider '{provider}'. Supported values: {supported}."
|
||||
INVALID_MONTHS = f"Invalid Premium duration: choose {', '.join(str(m) for m in sorted(PREMIUM_MONTHS_VALID))} months."
|
||||
INVALID_STARS_AMOUNT = (
|
||||
f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}."
|
||||
)
|
||||
INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months."
|
||||
INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000."
|
||||
INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1 000 000 000."
|
||||
INVALID_USERNAME = (
|
||||
"Invalid username '{username}'. "
|
||||
"Must be 5–32 characters and contain only letters (A–Z, a–z), digits (0–9), or underscores (_)."
|
||||
INVALID_GRAM_AMOUNT = f"Invalid GRAM (ex TON) amount: must be an integer between {GRAM_TOPUP_MIN:,} and {GRAM_TOPUP_MAX:,}."
|
||||
INVALID_WINNERS_STARS = (
|
||||
f"Invalid winners count: must be an integer between {STARS_WINNERS_MIN:,} and {STARS_WINNERS_MAX:,}."
|
||||
)
|
||||
INVALID_WINNERS_PREMIUM = (
|
||||
f"Invalid winners count: must be an integer between {PREMIUM_WINNERS_MIN:,} and {PREMIUM_WINNERS_MAX:,}."
|
||||
)
|
||||
INVALID_STARS_PER_WINNER = (
|
||||
f"Invalid Stars per winner: must be an integer between {STARS_GIVEAWAY_MIN:,} and {STARS_GIVEAWAY_MAX:,}."
|
||||
)
|
||||
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
|
||||
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000."
|
||||
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000."
|
||||
INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
|
||||
|
||||
|
||||
class CookieError(ClientError):
|
||||
"""Raised when cookies are unreadable or missing required fields."""
|
||||
|
||||
READ_FAILED = "Failed to parse cookies — expected a JSON string or a dict, got: {exc}"
|
||||
READ_FAILED = "Failed to parse cookies: expected a JSON string or a dict, got {exc}."
|
||||
MISSING_KEYS = (
|
||||
"Fragment cookies are missing or empty for key(s): {keys}. "
|
||||
"Open fragment.com in your browser, log in, and copy fresh cookies."
|
||||
)
|
||||
UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}."
|
||||
UNSUPPORTED_BROWSER = "Unsupported browser '{browser}'. Supported values: {supported}."
|
||||
BROWSER_READ_FAILED = (
|
||||
"Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}."
|
||||
)
|
||||
MISSING_BROWSER_KEYS = (
|
||||
"Fragment cookies not found in {browser}: {keys}. "
|
||||
"Make sure you are logged in to {url} and have connected your TON wallet in {browser}."
|
||||
"Make sure you are logged in to {url} and have connected your GRAM (ex TON) wallet in {browser}."
|
||||
)
|
||||
EXPIRED = "Fragment session cookie expired at {expires}. Log in to fragment.com in your browser and extract fresh cookies."
|
||||
|
||||
@@ -53,23 +70,14 @@ class CookieError(ClientError):
|
||||
class FragmentAPIError(FragmentError):
|
||||
"""Raised for errors returned by Fragment's API responses."""
|
||||
|
||||
NO_REQUEST_ID = (
|
||||
"Fragment did not return a request ID for '{context}'. "
|
||||
"Your session may have expired — log in to fragment.com and refresh your cookies."
|
||||
)
|
||||
NO_REQUEST_ID = "Fragment did not return a request ID for '{context}'. Your session may have expired. Refresh your cookies and try again."
|
||||
|
||||
|
||||
class FragmentPageError(FragmentAPIError):
|
||||
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
|
||||
|
||||
BAD_STATUS = (
|
||||
"Fragment returned HTTP {status} when loading {url}. "
|
||||
"Your cookies may be invalid or expired — log in to fragment.com and refresh them."
|
||||
)
|
||||
NOT_FOUND = (
|
||||
"Could not extract the API hash from {url}. "
|
||||
"The page structure may have changed, or you are not logged in — refresh your cookies."
|
||||
)
|
||||
BAD_STATUS = "Fragment returned HTTP {status} when loading {url}. Your cookies may be invalid or expired. Refresh them and try again."
|
||||
NOT_FOUND = "Could not extract the API hash from {url}. The page structure may have changed, or you may not be logged in. Refresh your cookies."
|
||||
|
||||
|
||||
class UserNotFoundError(FragmentAPIError):
|
||||
@@ -78,6 +86,13 @@ class UserNotFoundError(FragmentAPIError):
|
||||
NOT_FOUND = (
|
||||
"Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
|
||||
)
|
||||
NOT_A_USER = "'{username}' does not belong to a user account. Make sure the username is assigned to a personal Telegram account, not a channel or bot."
|
||||
|
||||
|
||||
class AlreadySubscribedError(FragmentAPIError):
|
||||
"""Raised when trying to gift Premium to a user who already has an active subscription."""
|
||||
|
||||
PREMIUM_ACTIVE = "This account is already subscribed to Telegram Premium."
|
||||
|
||||
|
||||
class AnonymousNumberError(FragmentAPIError):
|
||||
@@ -88,11 +103,9 @@ class AnonymousNumberError(FragmentAPIError):
|
||||
|
||||
|
||||
class TransactionError(FragmentAPIError):
|
||||
"""Raised when a TON transaction fails to build or broadcast."""
|
||||
"""Raised when a GRAM (ex TON) transaction fails to build or broadcast."""
|
||||
|
||||
INVALID_PAYLOAD = (
|
||||
"Fragment returned an invalid transaction payload — 'transaction.messages' is missing or empty in the API response."
|
||||
)
|
||||
INVALID_PAYLOAD = "Fragment returned an invalid transaction payload: 'transaction.messages' is missing or empty."
|
||||
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
||||
BROADCAST_FAILED_SSL = (
|
||||
"Transaction broadcast failed due to an SSL certificate error: {exc}\n"
|
||||
@@ -102,7 +115,7 @@ class TransactionError(FragmentAPIError):
|
||||
"located in your Python installation folder."
|
||||
)
|
||||
DUPLICATE_SEQNO = (
|
||||
"Transaction broadcast failed: the TON wallet rejected the message "
|
||||
"Transaction broadcast failed: the GRAM (ex TON) wallet rejected the message "
|
||||
"because a previous transaction with the same sequence number (seqno) "
|
||||
"is still pending confirmation on-chain.\n"
|
||||
"Wait a few seconds for the previous transaction to confirm, then retry."
|
||||
@@ -129,14 +142,16 @@ class OperationError(FragmentError):
|
||||
|
||||
|
||||
class WalletError(OperationError):
|
||||
"""Raised for TON wallet issues (connection, balance, account info)."""
|
||||
"""Raised for GRAM (ex TON) wallet issues (connection, balance, account info)."""
|
||||
|
||||
LOW_TON_BALANCE = "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required."
|
||||
LOW_GRAM_BALANCE = (
|
||||
"Insufficient GRAM (ex TON) balance: {balance:.4f} GRAM (ex TON) available, {required:.4f} GRAM (ex TON) required."
|
||||
)
|
||||
LOW_USDT_BALANCE = "Insufficient USDT balance: {balance:.4f} USDT available, {required:.4f} USDT required."
|
||||
TON_BALANCE_CHECK_FAILED = "Failed to fetch TON balance: {exc}"
|
||||
GRAM_BALANCE_CHECK_FAILED = "Failed to fetch GRAM (ex TON) balance: {exc}"
|
||||
USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}"
|
||||
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}"
|
||||
WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
|
||||
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from GRAM (ex TON) network: {exc}"
|
||||
WALLET_INFO_FAILED = "Failed to retrieve wallet info from GRAM (ex TON) network: {exc}"
|
||||
|
||||
|
||||
class UnexpectedError(OperationError):
|
||||
@@ -153,6 +168,7 @@ __all__ = [
|
||||
"FragmentAPIError",
|
||||
"FragmentPageError",
|
||||
"AnonymousNumberError",
|
||||
"AlreadySubscribedError",
|
||||
"UserNotFoundError",
|
||||
"TransactionError",
|
||||
"ParseError",
|
||||
@@ -1,25 +0,0 @@
|
||||
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
|
||||
from pyfragment.methods.giveaway_premium import giveaway_premium
|
||||
from pyfragment.methods.giveaway_stars import giveaway_stars
|
||||
from pyfragment.methods.purchase_premium import purchase_premium
|
||||
from pyfragment.methods.purchase_stars import purchase_stars
|
||||
from pyfragment.methods.recharge_ads import recharge_ads
|
||||
from pyfragment.methods.search_gifts import search_gifts
|
||||
from pyfragment.methods.search_numbers import search_numbers
|
||||
from pyfragment.methods.search_usernames import search_usernames
|
||||
from pyfragment.methods.topup_ton import topup_ton
|
||||
|
||||
__all__ = [
|
||||
"get_login_code",
|
||||
"giveaway_premium",
|
||||
"giveaway_stars",
|
||||
"purchase_premium",
|
||||
"purchase_stars",
|
||||
"recharge_ads",
|
||||
"search_gifts",
|
||||
"search_numbers",
|
||||
"search_usernames",
|
||||
"terminate_sessions",
|
||||
"toggle_login_codes",
|
||||
"topup_ton",
|
||||
]
|
||||
@@ -1,115 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
PremiumGiveawayResult,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
||||
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def giveaway_premium(
|
||||
client: FragmentClient,
|
||||
channel: str,
|
||||
winners: int,
|
||||
months: int = 3,
|
||||
payment_method: PaymentMethod = "ton",
|
||||
) -> PremiumGiveawayResult:
|
||||
"""Run a Telegram Premium giveaway for a channel.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||
winners: Number of winners — integer from ``1`` to ``24 000``.
|
||||
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
||||
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||
|
||||
Returns:
|
||||
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
|
||||
``winners``, and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``winners`` is not 1–24 000 or ``months`` is not 3, 6, or 12.
|
||||
UserNotFoundError: If the channel is not found on Fragment.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if not isinstance(winners, int) or not (1 <= winners <= 24_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
|
||||
if months not in (3, 6, 12):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||
method=payment_method,
|
||||
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await client.call(
|
||||
"searchPremiumGiveawayRecipient",
|
||||
{"query": channel, "quantity": winners, "months": months},
|
||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||
)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||
|
||||
result = await client.call(
|
||||
"initGiveawayPremiumRequest",
|
||||
{
|
||||
"recipient": recipient,
|
||||
"quantity": str(winners),
|
||||
"months": str(months),
|
||||
"payment_method": payment_method,
|
||||
},
|
||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||
)
|
||||
required_payment_amount = parse_required_payment_amount(result)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
|
||||
|
||||
account = await get_account_info(client)
|
||||
transaction = await client.call(
|
||||
"getGiveawayPremiumLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
},
|
||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(
|
||||
client,
|
||||
transaction,
|
||||
payment_method=payment_method,
|
||||
required_payment_amount=required_payment_amount,
|
||||
)
|
||||
return PremiumGiveawayResult(
|
||||
transaction_id=tx_hash,
|
||||
channel=channel,
|
||||
winners=winners,
|
||||
amount=months,
|
||||
)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -1,111 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
StarsGiveawayResult,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
||||
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def giveaway_stars(
|
||||
client: FragmentClient,
|
||||
channel: str,
|
||||
winners: int,
|
||||
amount: int,
|
||||
payment_method: PaymentMethod = "ton",
|
||||
) -> StarsGiveawayResult:
|
||||
"""Run a Telegram Stars giveaway for a channel.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
|
||||
winners: Number of winners — integer from ``1`` to ``5``.
|
||||
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
||||
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||
|
||||
Returns:
|
||||
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
|
||||
``winners``, and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``winners`` is not 1–5 or ``amount`` is not 500–1 000 000.
|
||||
UserNotFoundError: If the channel is not found on Fragment.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if not isinstance(winners, int) or not (1 <= winners <= 5):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
|
||||
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
|
||||
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||
method=payment_method,
|
||||
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
|
||||
|
||||
result = await client.call(
|
||||
"initGiveawayStarsRequest",
|
||||
{
|
||||
"recipient": recipient,
|
||||
"quantity": str(winners),
|
||||
"stars": str(amount),
|
||||
"payment_method": payment_method,
|
||||
},
|
||||
page_url=STARS_GIVEAWAY_PAGE,
|
||||
)
|
||||
required_payment_amount = parse_required_payment_amount(result)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
|
||||
|
||||
account = await get_account_info(client)
|
||||
transaction = await client.call(
|
||||
"getGiveawayStarsLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
},
|
||||
page_url=STARS_GIVEAWAY_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(
|
||||
client,
|
||||
transaction,
|
||||
payment_method=payment_method,
|
||||
required_payment_amount=required_payment_amount,
|
||||
)
|
||||
return StarsGiveawayResult(
|
||||
transaction_id=tx_hash,
|
||||
channel=channel,
|
||||
winners=winners,
|
||||
amount=amount,
|
||||
)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -1,105 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
PremiumResult,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
||||
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def purchase_premium(
|
||||
client: FragmentClient,
|
||||
username: str,
|
||||
months: int,
|
||||
show_sender: bool = True,
|
||||
payment_method: PaymentMethod = "ton",
|
||||
) -> PremiumResult:
|
||||
"""Gift Telegram Premium to a user.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||
months: Premium duration — ``3``, ``6``, or ``12``.
|
||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||
|
||||
Returns:
|
||||
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``months`` is not ``3``, ``6``, or ``12``.
|
||||
UserNotFoundError: If the user is not found on Fragment.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if months not in (3, 6, 12):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||
method=payment_method,
|
||||
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||
|
||||
await client.call(
|
||||
"updatePremiumState",
|
||||
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
|
||||
page_url=PREMIUM_PAGE,
|
||||
)
|
||||
result = await client.call(
|
||||
"initGiftPremiumRequest",
|
||||
{"recipient": recipient, "months": months, "payment_method": payment_method},
|
||||
page_url=PREMIUM_PAGE,
|
||||
)
|
||||
required_payment_amount = parse_required_payment_amount(result)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
|
||||
|
||||
account = await get_account_info(client)
|
||||
transaction = await client.call(
|
||||
"getGiftPremiumLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
},
|
||||
page_url=PREMIUM_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(
|
||||
client,
|
||||
transaction,
|
||||
payment_method=payment_method,
|
||||
required_payment_amount=required_payment_amount,
|
||||
)
|
||||
return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -1,101 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.types import (
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
StarsResult,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
)
|
||||
from pyfragment.types.constants import DEVICE, STARS_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
|
||||
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def purchase_stars(
|
||||
client: FragmentClient, username: str, amount: int, show_sender: bool = True, payment_method: PaymentMethod = "ton"
|
||||
) -> StarsResult:
|
||||
"""Send Telegram Stars to a user.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||
amount: Number of Stars to send — integer from ``50`` to ``1 000 000``.
|
||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
|
||||
|
||||
Returns:
|
||||
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``amount`` is not an integer between 50 and 1 000 000.
|
||||
UserNotFoundError: If the user is not found on Fragment.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
|
||||
if payment_method not in SUPPORTED_PAYMENT_METHODS:
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.INVALID_PAYMENT_METHOD.format(
|
||||
method=payment_method,
|
||||
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||
|
||||
await client.call(
|
||||
"updateStarsBuyState",
|
||||
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
|
||||
page_url=STARS_PAGE,
|
||||
)
|
||||
result = await client.call(
|
||||
"initBuyStarsRequest",
|
||||
{"recipient": recipient, "quantity": amount, "payment_method": payment_method},
|
||||
page_url=STARS_PAGE,
|
||||
)
|
||||
required_payment_amount = parse_required_payment_amount(result)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
|
||||
|
||||
account = await get_account_info(client)
|
||||
transaction = await client.call(
|
||||
"getBuyStarsLink",
|
||||
{
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
},
|
||||
page_url=STARS_PAGE,
|
||||
)
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
tx_hash = await process_transaction(
|
||||
client,
|
||||
transaction,
|
||||
payment_method=payment_method,
|
||||
required_payment_amount=required_payment_amount,
|
||||
)
|
||||
return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -1,75 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyfragment.types import FragmentAPIError, FragmentError, GiftsResult, UnexpectedError
|
||||
from pyfragment.types.constants import GIFTS_PAGE
|
||||
from pyfragment.utils import parse_gift_items
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def search_gifts(
|
||||
client: FragmentClient,
|
||||
query: str = "",
|
||||
collection: str | None = None,
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
view: str | None = None,
|
||||
attr: dict[str, list[str]] | None = None,
|
||||
offset: int | None = None,
|
||||
) -> GiftsResult:
|
||||
"""Search the Fragment gifts marketplace.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
query: Search text. Omit or pass ``""`` to browse without filtering by name.
|
||||
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
|
||||
(available items). Omit to return all.
|
||||
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
|
||||
attr: Attribute filters as a mapping of trait name to list of accepted values, e.g.
|
||||
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
|
||||
Each key is sent as ``attr[Key]`` with its list of values.
|
||||
offset: Integer page offset from a previous :class:`GiftsResult`.
|
||||
Pass ``next_offset`` to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`GiftsResult` with ``items`` (parsed list of item dicts) and
|
||||
``next_offset`` (``None`` on the last page).
|
||||
|
||||
Raises:
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
data: dict[str, Any] = {"type": "gifts", "query": query}
|
||||
if collection is not None:
|
||||
data["collection"] = collection
|
||||
if sort is not None:
|
||||
data["sort"] = sort
|
||||
if filter is not None:
|
||||
data["filter"] = filter
|
||||
if view is not None:
|
||||
data["view"] = view
|
||||
if attr is not None:
|
||||
for trait, values in attr.items():
|
||||
data[f"attr[{trait}]"] = values
|
||||
if offset is not None:
|
||||
data["offset"] = offset
|
||||
|
||||
try:
|
||||
result = await client.call("searchAuctions", data, page_url=GIFTS_PAGE)
|
||||
|
||||
if result.get("error"):
|
||||
raise FragmentAPIError(result["error"])
|
||||
|
||||
items, next_offset = parse_gift_items(result.get("html") or "")
|
||||
return GiftsResult(items=items, next_offset=next_offset)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -1,62 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyfragment.types import FragmentAPIError, FragmentError, NumbersResult, UnexpectedError
|
||||
from pyfragment.types.constants import NUMBERS_PAGE
|
||||
from pyfragment.utils import parse_auction_rows
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def search_numbers(
|
||||
client: FragmentClient,
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> NumbersResult:
|
||||
"""Search the Fragment marketplace for anonymous Telegram numbers.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
|
||||
(available items). Omit to return all.
|
||||
offset_id: Pagination cursor from a previous :class:`NumbersResult`.
|
||||
Pass ``next_offset_id`` to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`NumbersResult` with ``items`` (parsed list of item dicts) and
|
||||
``next_offset_id`` (``None`` when there are no more pages).
|
||||
|
||||
Raises:
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
data: dict[str, Any] = {"type": "numbers", "query": query}
|
||||
if sort is not None:
|
||||
data["sort"] = sort
|
||||
if filter is not None:
|
||||
data["filter"] = filter
|
||||
if offset_id is not None:
|
||||
data["offset_id"] = offset_id
|
||||
|
||||
try:
|
||||
result = await client.call("searchAuctions", data, page_url=NUMBERS_PAGE)
|
||||
|
||||
if result.get("error"):
|
||||
raise FragmentAPIError(result["error"])
|
||||
|
||||
items = parse_auction_rows(result.get("html") or "")
|
||||
raw_noi = result.get("next_offset_id")
|
||||
next_offset_id = str(raw_noi) if raw_noi else None
|
||||
return NumbersResult(items=items, next_offset_id=next_offset_id)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -1,62 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError, UsernamesResult
|
||||
from pyfragment.types.constants import FRAGMENT_BASE_URL
|
||||
from pyfragment.utils import parse_auction_rows
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def search_usernames(
|
||||
client: FragmentClient,
|
||||
query: str = "",
|
||||
sort: str | None = None,
|
||||
filter: str | None = None,
|
||||
offset_id: str | None = None,
|
||||
) -> UsernamesResult:
|
||||
"""Search the Fragment marketplace for Telegram usernames.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
|
||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
||||
``"ending"``. Omit to use Fragment's default ordering.
|
||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
|
||||
(available items). Omit to return all.
|
||||
offset_id: Pagination cursor from a previous :class:`UsernamesResult`.
|
||||
Pass ``next_offset_id`` to fetch the next page.
|
||||
|
||||
Returns:
|
||||
:class:`UsernamesResult` with ``items`` (parsed list of item dicts) and
|
||||
``next_offset_id`` (``None`` when there are no more pages).
|
||||
|
||||
Raises:
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
data: dict[str, Any] = {"type": "usernames", "query": query}
|
||||
if sort is not None:
|
||||
data["sort"] = sort
|
||||
if filter is not None:
|
||||
data["filter"] = filter
|
||||
if offset_id is not None:
|
||||
data["offset_id"] = offset_id
|
||||
|
||||
try:
|
||||
result = await client.call("searchAuctions", data, page_url=FRAGMENT_BASE_URL)
|
||||
|
||||
if result.get("error"):
|
||||
raise FragmentAPIError(result["error"])
|
||||
|
||||
items = parse_auction_rows(result.get("html") or "")
|
||||
raw_noi = result.get("next_offset_id")
|
||||
next_offset_id = str(raw_noi) if raw_noi else None
|
||||
return UsernamesResult(items=items, next_offset_id=next_offset_id)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,3 @@
|
||||
from pyfragment.services.cookies import CookieResult, get_cookies_from_browser
|
||||
|
||||
__all__ = ["CookieResult", "get_cookies_from_browser"]
|
||||
@@ -0,0 +1,4 @@
|
||||
from pyfragment.services.cookies.models import CookieResult
|
||||
from pyfragment.services.cookies.service import get_cookies_from_browser
|
||||
|
||||
__all__ = ["CookieResult", "get_cookies_from_browser"]
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CookieResult:
|
||||
cookies: dict[str, str]
|
||||
expires: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"CookieResult(cookies={self.cookies!r}, expires={self.expires!r})"
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS
|
||||
from pyfragment.enums import SupportedBrowser
|
||||
from pyfragment.exceptions import CookieError
|
||||
from pyfragment.services.cookies.models import CookieResult
|
||||
|
||||
try:
|
||||
import rookiepy
|
||||
except Exception: # noqa: BLE001
|
||||
rookiepy = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
|
||||
global rookiepy
|
||||
|
||||
key = browser.lower()
|
||||
if not any(key == m for m in SupportedBrowser):
|
||||
supported = ", ".join(sorted(b.value for b in SupportedBrowser))
|
||||
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
|
||||
|
||||
try:
|
||||
if rookiepy is None:
|
||||
rookiepy = importlib.import_module("rookiepy")
|
||||
|
||||
jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
|
||||
except Exception as exc:
|
||||
raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc
|
||||
|
||||
cookie_map: dict[str, str] = {c["name"]: c["value"] for c in jar if c.get("name") and c.get("value")}
|
||||
|
||||
missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookie_map.get(k, "")).strip()]
|
||||
if missing:
|
||||
raise CookieError(CookieError.MISSING_BROWSER_KEYS.format(browser=browser, keys=missing, url=FRAGMENT_BASE_URL))
|
||||
|
||||
expires_iso: str | None = None
|
||||
for cookie in jar:
|
||||
if cookie.get("name") == "stel_ssid":
|
||||
raw = cookie.get("expires")
|
||||
if isinstance(raw, (int, float)):
|
||||
expires_iso = datetime.fromtimestamp(raw, tz=UTC).isoformat()
|
||||
elif isinstance(raw, str) and raw:
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
|
||||
try:
|
||||
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=UTC).isoformat()
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
break
|
||||
|
||||
if expires_iso:
|
||||
expires_dt = datetime.fromisoformat(expires_iso)
|
||||
if expires_dt < datetime.now(UTC):
|
||||
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
|
||||
|
||||
return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso)
|
||||
@@ -0,0 +1,5 @@
|
||||
from pyfragment.services.tonapi.service import TonapiService
|
||||
|
||||
__all__ = [
|
||||
"TonapiService",
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ton_core import NetworkGlobalID
|
||||
from tonutils.clients import TonapiClient, ToncenterClient
|
||||
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
|
||||
from tonutils.exceptions import ProviderResponseError
|
||||
|
||||
from pyfragment.core.constants import MIN_GRAM_BALANCE, MIN_USDT_BALANCE, USDT_GRAM_MASTER_ADDRESS
|
||||
from pyfragment.enums import WALLET_CLASSES, ApiProvider
|
||||
from pyfragment.exceptions import WalletError
|
||||
from pyfragment.services.tonapi.models import WalletInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _make_ton_client(client: FragmentClient) -> Any:
|
||||
"""Return the appropriate tonutils client based on the configured api_provider."""
|
||||
if client.api_provider == ApiProvider.TONCENTER:
|
||||
return ToncenterClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key)
|
||||
return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key)
|
||||
|
||||
|
||||
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
|
||||
"""Return the USDT balance for a Fragment-linked GRAM (ex TON) wallet."""
|
||||
try:
|
||||
jetton_wallet_address = await get_wallet_address_get_method(
|
||||
client=ton,
|
||||
address=USDT_GRAM_MASTER_ADDRESS,
|
||||
owner_address=wallet_address,
|
||||
)
|
||||
wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address)
|
||||
raw_balance = int(wallet_data[0]) if wallet_data else 0
|
||||
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
|
||||
|
||||
|
||||
async def check_gram_payment_balance(
|
||||
balance_gram: float,
|
||||
amount_gram: float,
|
||||
required_payment_amount: float | None,
|
||||
) -> None:
|
||||
"""Validate that the GRAM (ex TON) wallet can cover a GRAM (ex TON)-denominated payment."""
|
||||
tx_price_gram = amount_gram
|
||||
if required_payment_amount is not None and required_payment_amount > 0:
|
||||
tx_price_gram = max(tx_price_gram, required_payment_amount)
|
||||
|
||||
required_gram = max(tx_price_gram, MIN_GRAM_BALANCE)
|
||||
if balance_gram < required_gram:
|
||||
logger.error(
|
||||
"Failed GRAM (ex TON) balance check: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)",
|
||||
round(balance_gram, 6),
|
||||
round(required_gram, 6),
|
||||
)
|
||||
raise WalletError(WalletError.LOW_GRAM_BALANCE.format(balance=balance_gram, required=required_gram))
|
||||
|
||||
|
||||
async def check_usdt_payment_balance(
|
||||
balance_gram: float,
|
||||
required_payment_amount: float | None,
|
||||
ton: Any,
|
||||
wallet_address: str,
|
||||
) -> None:
|
||||
"""Validate that the wallet can cover a USDT-denominated payment."""
|
||||
if balance_gram < MIN_GRAM_BALANCE:
|
||||
logger.error(
|
||||
"Failed GRAM (ex TON) gas reserve check for USDT payment: balance=%s GRAM (ex TON), required=%s GRAM (ex TON)",
|
||||
round(balance_gram, 6),
|
||||
MIN_GRAM_BALANCE,
|
||||
)
|
||||
raise WalletError(WalletError.LOW_GRAM_BALANCE.format(balance=balance_gram, required=MIN_GRAM_BALANCE))
|
||||
|
||||
usdt_balance = await get_usdt_balance(ton, wallet_address)
|
||||
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))
|
||||
|
||||
|
||||
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
|
||||
"""Build the wallet payload Fragment needs to prepare a transaction."""
|
||||
async with _make_ton_client(client) as ton:
|
||||
try:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
boc = wallet.state_init.serialize().to_boc()
|
||||
return {
|
||||
"address": wallet.address.to_str(False, False),
|
||||
"publicKey": pub_key.as_hex,
|
||||
"chain": "-239",
|
||||
"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
|
||||
|
||||
|
||||
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
|
||||
"""Fetch the wallet address, chain state, and GRAM (ex TON)/USDT balances."""
|
||||
async with _make_ton_client(client) as ton:
|
||||
try:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
await wallet.refresh()
|
||||
wallet_address = wallet.address.to_str(False, False)
|
||||
usdt_balance = await get_usdt_balance(ton, wallet_address)
|
||||
return WalletInfo(
|
||||
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
|
||||
state=wallet.state.value,
|
||||
gram_balance=round(wallet.balance / 1_000_000_000, 4),
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletInfo:
|
||||
address: str
|
||||
state: str
|
||||
gram_balance: float
|
||||
usdt_balance: float
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"WalletInfo(address='{self.address}', state='{self.state}', "
|
||||
f"gram_balance={self.gram_balance} GRAM (ex TON), usdt_balance={self.usdt_balance} USDT)"
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pyfragment.domains.base import BaseService
|
||||
from pyfragment.services.tonapi.account import get_wallet_info
|
||||
from pyfragment.services.tonapi.models import WalletInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class TonapiService(BaseService):
|
||||
async def get_wallet(self) -> WalletInfo:
|
||||
return await get_wallet_info(self._client)
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import random
|
||||
import ssl
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ton_core import Cell
|
||||
from tonutils.exceptions import ProviderResponseError
|
||||
|
||||
from pyfragment.enums import WALLET_CLASSES, PaymentMethod
|
||||
from pyfragment.exceptions import ParseError, TransactionError, WalletError
|
||||
from pyfragment.services.tonapi.account import _make_ton_client, check_gram_payment_balance, check_usdt_payment_balance
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def clean_decode(payload: str) -> str | Cell:
|
||||
"""Decode a base64 BOC comment from Fragment into text when possible.
|
||||
|
||||
Some Fragment payloads are plain text comments, while others are structured
|
||||
GRAM (ex TON) messages such as jetton transfers. Non-text payloads are returned as a
|
||||
`Cell` so the caller can keep the raw binary structure.
|
||||
"""
|
||||
s = payload.strip()
|
||||
if not s:
|
||||
return ""
|
||||
s += "=" * (-len(s) % 4)
|
||||
try:
|
||||
boc = base64.b64decode(s, altchars=b"-_", validate=True)
|
||||
cell = Cell.one_from_boc(boc)
|
||||
sl = cell.begin_parse()
|
||||
op = sl.load_uint(32)
|
||||
if op != 0:
|
||||
# Non-zero op code means this is a structured GRAM (ex TON) message, not a plain text comment.
|
||||
return cell
|
||||
try:
|
||||
return sl.load_snake_string().strip()
|
||||
except UnicodeDecodeError:
|
||||
return cell
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to decode Fragment payload")
|
||||
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
|
||||
|
||||
|
||||
def _extract_message(transaction_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate and extract the first message from a Fragment transaction payload."""
|
||||
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
|
||||
logger.error("Failed to process transaction: missing transaction payload or messages")
|
||||
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
||||
result: dict[str, Any] = transaction_data["transaction"]["messages"][0]
|
||||
return result
|
||||
|
||||
|
||||
async def _check_payment_balances(
|
||||
wallet: Any,
|
||||
payment_method: PaymentMethod,
|
||||
amount_gram: float,
|
||||
required_payment_amount: float | None,
|
||||
transaction_data: dict[str, Any],
|
||||
ton: Any,
|
||||
) -> None:
|
||||
"""Refresh wallet and verify sufficient balance before broadcasting."""
|
||||
try:
|
||||
await wallet.refresh()
|
||||
balance_gram = wallet.balance / 1_000_000_000
|
||||
if payment_method == "ton":
|
||||
await check_gram_payment_balance(balance_gram, amount_gram, required_payment_amount)
|
||||
else:
|
||||
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
|
||||
fragment_wallet_address = transaction_data["transaction"].get("from", "")
|
||||
await check_usdt_payment_balance(balance_gram, required_payment_amount, ton, fragment_wallet_address)
|
||||
except WalletError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to validate balances before broadcasting transaction")
|
||||
raise WalletError(WalletError.GRAM_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
||||
|
||||
|
||||
async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: str | Cell) -> str:
|
||||
"""Attempt to broadcast a transaction up to 3 times, handling rate-limit and seqno errors."""
|
||||
for attempt in range(3):
|
||||
try:
|
||||
result = await wallet.transfer(
|
||||
destination=message["address"],
|
||||
amount=int(message["amount"]), # nanograms, not GRAM (ex TON)
|
||||
body=payload,
|
||||
)
|
||||
return str(result.normalized_hash)
|
||||
except ProviderResponseError as exc:
|
||||
if exc.code == 429 and attempt == 0:
|
||||
logger.warning(
|
||||
"Broadcast rate-limited (429), retrying transaction once: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await asyncio.sleep(1 + random.uniform(0, 0.5))
|
||||
continue
|
||||
if exc.code == 406 and "seqno" in str(exc).lower():
|
||||
if attempt < 2:
|
||||
logger.warning(
|
||||
"Broadcast seqno conflict (406), retrying attempt %s: %s",
|
||||
attempt + 2,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await asyncio.sleep(2 + random.uniform(0, 1))
|
||||
continue
|
||||
logger.error("Failed to broadcast transaction after seqno retries")
|
||||
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
|
||||
raise
|
||||
|
||||
logger.error("Failed to broadcast transaction: transfer loop exited without result")
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
|
||||
|
||||
|
||||
async def process_transaction(
|
||||
client: FragmentClient,
|
||||
transaction_data: dict[str, Any],
|
||||
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||
required_payment_amount: float | None = None,
|
||||
) -> str:
|
||||
"""Sign and broadcast a Fragment transaction with the seeded GRAM (ex TON) wallet.
|
||||
|
||||
Args:
|
||||
client: Authenticated `FragmentClient` instance.
|
||||
transaction_data: Raw Fragment transaction payload returned by the API.
|
||||
payment_method: Payment currency to use for the purchase flow.
|
||||
required_payment_amount: Optional amount returned by Fragment's init request.
|
||||
|
||||
Returns:
|
||||
Normalized transaction hash string.
|
||||
"""
|
||||
message = _extract_message(transaction_data)
|
||||
amount_gram = int(message["amount"]) / 1_000_000_000
|
||||
|
||||
async with _make_ton_client(client) as ton:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
|
||||
await _check_payment_balances(wallet, payment_method, amount_gram, required_payment_amount, transaction_data, ton)
|
||||
|
||||
payload = clean_decode(str(message.get("payload", "")))
|
||||
|
||||
try:
|
||||
return await _broadcast_with_retry(wallet, message, payload)
|
||||
except (WalletError, TransactionError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
cause: BaseException | None = exc
|
||||
while cause is not None:
|
||||
if isinstance(cause, ssl.SSLError):
|
||||
logger.exception("Failed to broadcast transaction due to SSL error")
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
|
||||
cause = cause.__cause__ or cause.__context__
|
||||
logger.exception(
|
||||
"Failed to broadcast transaction to '%s' for %s nanograms using payment method '%s'",
|
||||
message["address"],
|
||||
message["amount"],
|
||||
payment_method,
|
||||
)
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
||||
@@ -1,67 +0,0 @@
|
||||
from pyfragment.types.constants import PaymentMethod
|
||||
from pyfragment.types.exceptions import (
|
||||
AnonymousNumberError,
|
||||
ClientError,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
FragmentPageError,
|
||||
OperationError,
|
||||
ParseError,
|
||||
TransactionError,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
WalletError,
|
||||
)
|
||||
from pyfragment.types.results import (
|
||||
AdsRechargeResult,
|
||||
AdsTopupResult,
|
||||
CookieResult,
|
||||
GiftsResult,
|
||||
LoginCodeResult,
|
||||
NumbersResult,
|
||||
PremiumGiveawayResult,
|
||||
PremiumResult,
|
||||
StarsGiveawayResult,
|
||||
StarsResult,
|
||||
TerminateSessionsResult,
|
||||
UsernamesResult,
|
||||
WalletInfo,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# client exceptions
|
||||
"ClientError",
|
||||
"ConfigurationError",
|
||||
"CookieError",
|
||||
# fragment exceptions
|
||||
"FragmentAPIError",
|
||||
"FragmentError",
|
||||
"FragmentPageError",
|
||||
"AnonymousNumberError",
|
||||
"OperationError",
|
||||
"ParseError",
|
||||
"TransactionError",
|
||||
"UnexpectedError",
|
||||
"UserNotFoundError",
|
||||
"VerificationError",
|
||||
"WalletError",
|
||||
# result types
|
||||
"AdsRechargeResult",
|
||||
"AdsTopupResult",
|
||||
"CookieResult",
|
||||
"GiftsResult",
|
||||
"LoginCodeResult",
|
||||
"NumbersResult",
|
||||
"PremiumGiveawayResult",
|
||||
"PremiumResult",
|
||||
"StarsGiveawayResult",
|
||||
"StarsResult",
|
||||
"TerminateSessionsResult",
|
||||
"UsernamesResult",
|
||||
"WalletInfo",
|
||||
# literal types
|
||||
"PaymentMethod",
|
||||
]
|
||||
@@ -1,96 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
||||
|
||||
# Payment methods
|
||||
PaymentMethod = Literal["ton", "usdt_ton"]
|
||||
SUPPORTED_PAYMENT_METHODS: frozenset[str] = frozenset(get_args(PaymentMethod))
|
||||
|
||||
# Single source of truth for supported wallet versions
|
||||
WalletVersion = Literal["V4R2", "V5R1"]
|
||||
SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
|
||||
|
||||
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
|
||||
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
||||
|
||||
# Minimum TON balance threshold required for payment flows.
|
||||
MIN_TON_BALANCE: float = 0.33
|
||||
|
||||
# USDT (TON) jetton metadata used for payment-method balance checks.
|
||||
USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
|
||||
MIN_USDT_BALANCE: float = 0.75
|
||||
|
||||
# Default HTTP request timeout in seconds.
|
||||
DEFAULT_TIMEOUT: float = 30.0
|
||||
|
||||
# Required Fragment session cookie keys
|
||||
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
||||
|
||||
# Fragment domain and page URLs
|
||||
FRAGMENT_DOMAIN: str = "fragment.com" # for rookiepy
|
||||
FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}"
|
||||
STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
|
||||
STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway"
|
||||
PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift"
|
||||
PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway"
|
||||
ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
|
||||
NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
|
||||
GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts"
|
||||
|
||||
# Browsers supported by get_cookies_from_browser()
|
||||
SUPPORTED_BROWSERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"arc",
|
||||
"brave",
|
||||
"chrome",
|
||||
"chromium",
|
||||
"chromium_based",
|
||||
"edge",
|
||||
"firefox",
|
||||
"firefox_based",
|
||||
"librewolf",
|
||||
"opera",
|
||||
"opera_gx",
|
||||
"safari",
|
||||
"vivaldi",
|
||||
}
|
||||
)
|
||||
|
||||
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
|
||||
DEVICE: str = json.dumps(
|
||||
{
|
||||
"platform": "iphone",
|
||||
"appName": "Tonkeeper",
|
||||
"appVersion": "26.04.0",
|
||||
"maxProtocolVersion": 2,
|
||||
"features": [
|
||||
"SendTransaction",
|
||||
{"name": "SendTransaction", "maxMessages": 255},
|
||||
{"name": "SignData", "types": ["text", "binary", "cell"]},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Base HTTP headers — shared across all Fragment API requests.
|
||||
# Each method merges these with its own "referer" and "x-aj-referer".
|
||||
BASE_HEADERS: dict[str, str] = {
|
||||
"accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7",
|
||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"origin": FRAGMENT_BASE_URL,
|
||||
"priority": "u=1, i",
|
||||
"sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
|
||||
"sec-ch-ua-mobile": "?1",
|
||||
"sec-ch-ua-platform": '"Android"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-origin",
|
||||
"user-agent": (
|
||||
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
|
||||
),
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class CookieResult:
|
||||
"""Result returned by :func:`~pyfragment.utils.get_cookies_from_browser`.
|
||||
|
||||
Attributes:
|
||||
cookies: Dict with the four required Fragment cookie keys.
|
||||
expires: Expiry of the ``stel_ssid`` session cookie in ISO 8601 format (UTC),
|
||||
or ``None`` for session cookies.
|
||||
"""
|
||||
|
||||
cookies: dict[str, str]
|
||||
expires: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"CookieResult(expires={self.expires!r})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletInfo:
|
||||
"""Wallet state returned by :meth:`FragmentClient.get_wallet`."""
|
||||
|
||||
address: str
|
||||
state: str
|
||||
ton_balance: float
|
||||
usdt_balance: float
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"WalletInfo(address='{self.address}', state='{self.state}', "
|
||||
f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PremiumResult:
|
||||
"""Result of a successful Telegram Premium gift."""
|
||||
|
||||
transaction_id: str
|
||||
username: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StarsResult:
|
||||
"""Result of a successful Telegram Stars purchase."""
|
||||
|
||||
transaction_id: str
|
||||
username: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdsTopupResult:
|
||||
"""Result of a successful Telegram Ads balance top-up."""
|
||||
|
||||
transaction_id: str
|
||||
username: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StarsGiveawayResult:
|
||||
"""Result of a successful Telegram Stars giveaway."""
|
||||
|
||||
transaction_id: str
|
||||
channel: str
|
||||
winners: int
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"StarsGiveawayResult(channel='{self.channel}', winners={self.winners}, "
|
||||
f"amount={self.amount} stars per winner, tx='{self.transaction_id}')"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PremiumGiveawayResult:
|
||||
"""Result of a successful Telegram Premium giveaway."""
|
||||
|
||||
transaction_id: str
|
||||
channel: str
|
||||
winners: int
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, "
|
||||
f"amount={self.amount} months per winner, tx='{self.transaction_id}')"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginCodeResult:
|
||||
"""Result of :meth:`FragmentClient.get_login_code`."""
|
||||
|
||||
number: str
|
||||
code: str | None
|
||||
active_sessions: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
code_str = f"'{self.code}'" if self.code else "None"
|
||||
return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdsRechargeResult:
|
||||
"""Result of a successful self-recharge of Telegram Ads balance."""
|
||||
|
||||
transaction_id: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminateSessionsResult:
|
||||
"""Result of :meth:`FragmentClient.terminate_sessions`."""
|
||||
|
||||
number: str
|
||||
message: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsernamesResult:
|
||||
"""Result of :meth:`FragmentClient.search_usernames`.
|
||||
|
||||
Each dict in ``items`` has the keys:
|
||||
|
||||
- ``slug`` — URL path (e.g. ``"username/durov"``).
|
||||
- ``name`` — display value (e.g. ``"@durov"``).
|
||||
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
|
||||
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``.
|
||||
- ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``.
|
||||
|
||||
Use ``next_offset_id`` to paginate to the next page of results.
|
||||
"""
|
||||
|
||||
items: list[dict[str, Any]]
|
||||
next_offset_id: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"UsernamesResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NumbersResult:
|
||||
"""Result of :meth:`FragmentClient.search_numbers`.
|
||||
|
||||
Each dict in ``items`` has the keys:
|
||||
|
||||
- ``slug`` — URL path (e.g. ``"number/8880000111"``).
|
||||
- ``name`` — display value (e.g. ``"+888 0000 111"``).
|
||||
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
|
||||
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``.
|
||||
- ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``.
|
||||
|
||||
Use ``next_offset_id`` to paginate to the next page of results.
|
||||
"""
|
||||
|
||||
items: list[dict[str, Any]]
|
||||
next_offset_id: str | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"NumbersResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GiftsResult:
|
||||
"""Result of :meth:`FragmentClient.search_gifts`.
|
||||
|
||||
Each dict in ``items`` has the keys:
|
||||
|
||||
- ``slug`` — URL path (e.g. ``"gift/plushpepe-1821"``).
|
||||
- ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``).
|
||||
- ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``).
|
||||
- ``price`` — price in TON formatted to two decimal places (e.g. ``"88888.00"``), or ``None``.
|
||||
- ``date`` — ISO 8601 datetime of the sale/listing, or ``None``.
|
||||
|
||||
Use ``next_offset`` to paginate to the next page of results.
|
||||
"""
|
||||
|
||||
items: list[dict[str, Any]]
|
||||
next_offset: int | None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AdsRechargeResult",
|
||||
"AdsTopupResult",
|
||||
"GiftsResult",
|
||||
"LoginCodeResult",
|
||||
"NumbersResult",
|
||||
"PremiumGiveawayResult",
|
||||
"PremiumResult",
|
||||
"StarsGiveawayResult",
|
||||
"StarsResult",
|
||||
"TerminateSessionsResult",
|
||||
"UsernamesResult",
|
||||
"WalletInfo",
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser
|
||||
from pyfragment.utils.decoder import clean_decode
|
||||
from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code, parse_required_payment_amount
|
||||
from pyfragment.utils.http import (
|
||||
execute_transaction_request,
|
||||
fragment_request,
|
||||
get_fragment_hash,
|
||||
make_headers,
|
||||
parse_json_response,
|
||||
)
|
||||
from pyfragment.utils.wallet import get_account_info, process_transaction
|
||||
|
||||
__all__ = [
|
||||
"clean_decode",
|
||||
"CookieResult",
|
||||
"get_cookies_from_browser",
|
||||
"parse_auction_rows",
|
||||
"parse_gift_items",
|
||||
"parse_login_code",
|
||||
"parse_required_payment_amount",
|
||||
"execute_transaction_request",
|
||||
"fragment_request",
|
||||
"get_account_info",
|
||||
"get_fragment_hash",
|
||||
"make_headers",
|
||||
"parse_json_response",
|
||||
"process_transaction",
|
||||
]
|
||||
@@ -1,72 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import rookiepy
|
||||
|
||||
from pyfragment.types import CookieError
|
||||
from pyfragment.types import CookieResult as CookieResult
|
||||
from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
|
||||
|
||||
|
||||
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
|
||||
"""Extract Fragment session cookies directly from an installed browser.
|
||||
|
||||
Reads the browser's on-disk cookie store (no extension required) and
|
||||
returns the four cookies required by :class:`~pyfragment.FragmentClient`
|
||||
along with the session expiry timestamp.
|
||||
|
||||
Args:
|
||||
browser: Browser name to read cookies from — case-insensitive. Supported values:
|
||||
``"chrome"`` (default), ``"firefox"``, ``"edge"``, ``"brave"``, ``"arc"``,
|
||||
``"opera"``, ``"opera_gx"``, ``"chromium"``, ``"chromium_based"``,
|
||||
``"firefox_based"``, ``"vivaldi"``, ``"librewolf"``, ``"safari"``.
|
||||
|
||||
Returns:
|
||||
:class:`CookieResult` with ``.cookies`` (dict) and ``.expires`` (ISO 8601 string or ``None``).
|
||||
|
||||
Raises:
|
||||
CookieError: If the browser is not supported, cookies cannot be read,
|
||||
or required keys are missing.
|
||||
"""
|
||||
key = browser.lower()
|
||||
if key not in SUPPORTED_BROWSERS:
|
||||
supported = ", ".join(sorted(SUPPORTED_BROWSERS))
|
||||
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
|
||||
|
||||
try:
|
||||
jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
|
||||
except Exception as exc:
|
||||
raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc
|
||||
|
||||
cookie_map: dict[str, str] = {c["name"]: c["value"] for c in jar if c.get("name") and c.get("value")}
|
||||
|
||||
missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookie_map.get(k, "")).strip()]
|
||||
if missing:
|
||||
raise CookieError(CookieError.MISSING_BROWSER_KEYS.format(browser=browser, keys=missing, url=FRAGMENT_BASE_URL))
|
||||
|
||||
expires_iso: str | None = None
|
||||
for cookie in jar:
|
||||
if cookie.get("name") == "stel_ssid":
|
||||
raw = cookie.get("expires")
|
||||
if isinstance(raw, (int, float)):
|
||||
expires_iso = datetime.fromtimestamp(raw, tz=timezone.utc).isoformat()
|
||||
elif isinstance(raw, str) and raw:
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
|
||||
try:
|
||||
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc).isoformat()
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
break
|
||||
|
||||
if expires_iso:
|
||||
expires_dt = datetime.fromisoformat(expires_iso)
|
||||
if expires_dt < datetime.now(timezone.utc):
|
||||
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
|
||||
|
||||
return CookieResult(
|
||||
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
|
||||
expires=expires_iso,
|
||||
)
|
||||
@@ -1,43 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from ton_core import Cell
|
||||
|
||||
from pyfragment.types import ParseError
|
||||
|
||||
|
||||
def clean_decode(payload: str) -> str | Cell:
|
||||
"""Decode a base64-encoded BOC payload to a plain-text comment string.
|
||||
|
||||
Fragment transaction payloads are BOC-serialised TVM cells. This function
|
||||
base64-decodes the payload, parses the cell, skips the 32-bit op-code
|
||||
prefix, and reads the snake-encoded UTF-8 comment.
|
||||
|
||||
Args:
|
||||
payload: Base64url-encoded BOC string (padding is added automatically).
|
||||
|
||||
Returns:
|
||||
Decoded comment string, ``""`` for an empty payload, or raw ``Cell``
|
||||
when payload is a non-UTF8 binary body.
|
||||
|
||||
Raises:
|
||||
ParseError: If the payload cannot be decoded or parsed.
|
||||
"""
|
||||
s = payload.strip()
|
||||
if not s:
|
||||
return ""
|
||||
s += "=" * (-len(s) % 4)
|
||||
try:
|
||||
# Fragment may return URL-safe base64 ("-"/"_") in transaction payloads.
|
||||
boc = base64.b64decode(s, altchars=b"-_", validate=True)
|
||||
cell = Cell.one_from_boc(boc)
|
||||
sl = cell.begin_parse()
|
||||
sl.load_uint(32) # op code
|
||||
try:
|
||||
return sl.load_snake_string().strip()
|
||||
except UnicodeDecodeError:
|
||||
# Some Fragment payloads are binary TVM cells rather than text comments.
|
||||
return cell
|
||||
except Exception as exc:
|
||||
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
|
||||
@@ -1,172 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Matches the login code inside a table-cell-value element.
|
||||
CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
|
||||
# Counts active session rows in the HTML table.
|
||||
ROW_RE = re.compile(r"<tr[\s>]")
|
||||
|
||||
# Auction table row parsing
|
||||
ROW_BLOCK_RE = re.compile(r'<tr\b[^>]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)</tr>', re.DOTALL)
|
||||
HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"')
|
||||
VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<')
|
||||
PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<")
|
||||
DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>')
|
||||
DATETIME_SHORT_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>')
|
||||
# Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888")
|
||||
NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$")
|
||||
|
||||
# Gift grid item parsing
|
||||
GRID_ITEM_RE = re.compile(r'<a\b[^>]*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)</a>', re.DOTALL)
|
||||
GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))')
|
||||
GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<')
|
||||
GRID_NUM_RE = re.compile(r'class="item-num">[^#]*#(\w+)<')
|
||||
GRID_PRICE_RE = re.compile(r'class="[^"]*tm-grid-item-value[^"]*icon-ton[^"]*"[^>]*>\s*([0-9][^<]*?)\s*<')
|
||||
GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<]+?)\s*<')
|
||||
GRID_DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"')
|
||||
|
||||
|
||||
def parse_login_code(html: str) -> tuple[str | None, int]:
|
||||
"""Extract the pending login code and active session count from a Fragment numbers page HTML snippet.
|
||||
|
||||
Args:
|
||||
html: Raw HTML string returned by the Fragment API.
|
||||
|
||||
Returns:
|
||||
A tuple of ``(code, active_sessions)`` where ``code`` is ``None`` if no
|
||||
pending code is present, and ``active_sessions`` is the number of ``<tr>``
|
||||
rows found (each row represents one active session).
|
||||
"""
|
||||
match = CODE_RE.search(html)
|
||||
code = match.group(1).strip() if match else None
|
||||
active_sessions = len(ROW_RE.findall(html))
|
||||
return code, active_sessions
|
||||
|
||||
|
||||
def parse_auction_rows(html: str) -> list[dict[str, Any]]:
|
||||
"""Parse Fragment marketplace HTML into structured item dicts.
|
||||
|
||||
Extracts each ``<tr class="tm-row-selectable">`` and returns a list of dicts
|
||||
with the following keys:
|
||||
|
||||
- ``slug`` — URL path segment (e.g. ``"username/durov"``).
|
||||
- ``name`` — display value (e.g. ``"@durov"`` or ``"+888..."``)
|
||||
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
|
||||
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``),
|
||||
or ``None`` if not listed.
|
||||
- ``date`` — ISO 8601 datetime string: auction end date, sale date, or listing date, or ``None``.
|
||||
|
||||
Returns:
|
||||
List of item dicts, one per table row.
|
||||
"""
|
||||
items: list[dict[str, Any]] = []
|
||||
for row_match in ROW_BLOCK_RE.finditer(html):
|
||||
row = row_match.group(1)
|
||||
|
||||
href_m = HREF_RE.search(row)
|
||||
if not href_m:
|
||||
continue
|
||||
slug = href_m.group(1).lstrip("/") # e.g. "username/durov"
|
||||
|
||||
# All tm-value spans in the row — first is the display name
|
||||
values = [m.group(1).strip() for m in VALUE_RE.finditer(row)]
|
||||
name = values[0] if values else slug
|
||||
|
||||
# Status: find the human-readable label from subsequent tm-value spans.
|
||||
# Skip usernames (@), numeric-only values (prices like "150,492", phone numbers like "+888 0088 8888").
|
||||
status: str | None = None
|
||||
for v in values[1:]:
|
||||
if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v):
|
||||
status = v
|
||||
break
|
||||
|
||||
# Price — look for icon-ton pattern, format as two decimal places
|
||||
price_m = PRICE_RE.search(row)
|
||||
price: str | None = None
|
||||
if price_m:
|
||||
raw_price = price_m.group(1).strip().replace(",", "")
|
||||
try:
|
||||
price = f"{float(raw_price):.2f}"
|
||||
except ValueError:
|
||||
price = raw_price
|
||||
|
||||
# Datetime (ISO 8601) — auction end, sale date, or listing date.
|
||||
time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row)
|
||||
date: str | None = time_m.group(1) if time_m else None
|
||||
|
||||
items.append(
|
||||
{
|
||||
"slug": slug,
|
||||
"name": name,
|
||||
"status": status,
|
||||
"price": price,
|
||||
"date": date,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
|
||||
"""Parse Fragment gifts grid HTML into structured item dicts.
|
||||
|
||||
Extracts each ``<a class="tm-grid-item">`` block and returns a list of dicts
|
||||
with the following keys:
|
||||
|
||||
- ``slug`` — URL path segment (e.g. ``"gift/plushpepe-1821"``).
|
||||
- ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``).
|
||||
- ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``).
|
||||
- ``price`` — price in TON formatted to two decimal places, or ``None``.
|
||||
- ``date`` — ISO 8601 datetime of the sale/listing, or ``None``.
|
||||
|
||||
Returns:
|
||||
Tuple of ``(items, next_offset)`` where ``next_offset`` is an integer
|
||||
page offset from ``data-next-offset``, or ``None`` on the last page.
|
||||
"""
|
||||
items: list[dict[str, Any]] = []
|
||||
for item_match in GRID_ITEM_RE.finditer(html):
|
||||
block = item_match.group(0)
|
||||
|
||||
href_m = GRID_HREF_RE.search(block)
|
||||
if not href_m:
|
||||
continue
|
||||
slug = href_m.group(1).lstrip("/") # e.g. "gift/plushpepe-1821"
|
||||
|
||||
name_m = GRID_NAME_RE.search(block)
|
||||
num_m = GRID_NUM_RE.search(block)
|
||||
item_name = name_m.group(1).strip() if name_m else slug
|
||||
item_num = f" #{num_m.group(1)}" if num_m else ""
|
||||
name = f"{item_name}{item_num}"
|
||||
|
||||
status_m = GRID_STATUS_RE.search(block)
|
||||
status: str | None = status_m.group(1).strip() if status_m else None
|
||||
|
||||
price_m = GRID_PRICE_RE.search(block)
|
||||
price: str | None = None
|
||||
if price_m:
|
||||
raw_price = price_m.group(1).strip().replace(",", "")
|
||||
try:
|
||||
price = f"{float(raw_price):.2f}"
|
||||
except ValueError:
|
||||
price = raw_price
|
||||
|
||||
time_m = GRID_DATETIME_RE.search(block)
|
||||
date: str | None = time_m.group(1) if time_m else None
|
||||
|
||||
items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date})
|
||||
|
||||
# Pagination offset from data-next-offset attribute
|
||||
next_offset_m = re.search(r'data-next-offset="(\d+)"', html)
|
||||
next_offset = int(next_offset_m.group(1)) if next_offset_m else None
|
||||
|
||||
return items, next_offset
|
||||
|
||||
|
||||
def parse_required_payment_amount(init_response: dict[str, Any]) -> float | None:
|
||||
"""Extract required payment amount from init*Request response."""
|
||||
raw_amount = init_response.get("amount")
|
||||
try:
|
||||
return float(str(raw_amount))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -1,154 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import re
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.types import FragmentPageError, ParseError, VerificationError
|
||||
from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
|
||||
|
||||
|
||||
def make_headers(page_url: str = FRAGMENT_BASE_URL) -> dict[str, str]:
|
||||
return {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
|
||||
|
||||
|
||||
async def get_fragment_hash(
|
||||
cookies: dict[str, Any],
|
||||
headers: dict[str, str],
|
||||
page_url: str,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> str:
|
||||
"""Fetch the API hash from a Fragment page.
|
||||
|
||||
Fragment embeds a short-lived hash in each page's HTML that must be
|
||||
included in every subsequent API request. This function loads the page
|
||||
as a real browser navigation (not XHR) so Fragment returns full HTML.
|
||||
|
||||
Args:
|
||||
cookies: Active Fragment session cookies.
|
||||
headers: Base headers for the relevant Fragment page.
|
||||
page_url: URL of the Fragment page to fetch the hash from.
|
||||
timeout: HTTP request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``.
|
||||
|
||||
Returns:
|
||||
Lowercase hex hash string.
|
||||
|
||||
Raises:
|
||||
FragmentPageError: If the page returns a non-200 status or the hash
|
||||
is not found in the response HTML.
|
||||
"""
|
||||
page_headers = {
|
||||
k: v
|
||||
for k, v in headers.items()
|
||||
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
|
||||
}
|
||||
page_headers.update(
|
||||
{
|
||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"referer": f"{FRAGMENT_BASE_URL}/",
|
||||
"sec-fetch-dest": "document",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"upgrade-insecure-requests": "1",
|
||||
}
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
|
||||
response = await session.get(page_url, headers=page_headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
|
||||
|
||||
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
|
||||
if not match:
|
||||
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
|
||||
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
|
||||
"""Parse a Fragment API JSON response.
|
||||
|
||||
Args:
|
||||
response: The HTTP response object.
|
||||
context: Human-readable name of the API method, used in error messages.
|
||||
|
||||
Returns:
|
||||
Parsed response as a dict.
|
||||
|
||||
Raises:
|
||||
ParseError: If the response body cannot be decoded as JSON.
|
||||
"""
|
||||
try:
|
||||
return cast(dict[str, Any], response.json())
|
||||
except Exception as exc:
|
||||
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
||||
|
||||
|
||||
async def fragment_request(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
headers: dict[str, str],
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""POST a single request to the Fragment API.
|
||||
|
||||
Builds the ``/api?hash=`` URL, sends the request, and returns the
|
||||
parsed JSON body. Use this for every API method call — search,
|
||||
init, state updates, etc.
|
||||
|
||||
Args:
|
||||
session: Active httpx session with Fragment cookies.
|
||||
fragment_hash: Short-lived hash from the Fragment page HTML.
|
||||
headers: Page-specific HTTP headers.
|
||||
data: Form data payload; must include a ``"method"`` key.
|
||||
|
||||
Returns:
|
||||
Parsed API response as a dict.
|
||||
"""
|
||||
for attempt in range(3):
|
||||
resp = await session.post(
|
||||
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
|
||||
headers=headers,
|
||||
data=data,
|
||||
)
|
||||
if resp.status_code == 429 and attempt < 2:
|
||||
await asyncio.sleep(1 + attempt + random.uniform(0, 0.5))
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
raise FragmentPageError(
|
||||
FragmentPageError.BAD_STATUS.format(status=resp.status_code, url=f"{FRAGMENT_BASE_URL}/api")
|
||||
)
|
||||
return parse_json_response(resp, data.get("method", "request"))
|
||||
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
|
||||
|
||||
|
||||
async def execute_transaction_request(
|
||||
session: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
tx_data: dict[str, Any],
|
||||
fragment_hash: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Post a transaction request to the Fragment API.
|
||||
|
||||
Args:
|
||||
session: Active httpx session with Fragment cookies.
|
||||
headers: Page-specific HTTP headers.
|
||||
tx_data: Form data payload for the API method.
|
||||
fragment_hash: Short-lived hash from the Fragment page.
|
||||
|
||||
Returns:
|
||||
Parsed API response dict containing transaction data.
|
||||
|
||||
Raises:
|
||||
VerificationError: If Fragment requires KYC verification.
|
||||
ParseError: If the response cannot be parsed.
|
||||
"""
|
||||
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
|
||||
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
return transaction
|
||||
@@ -1,241 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import random
|
||||
import ssl
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ton_core import NetworkGlobalID
|
||||
from tonutils.clients import TonapiClient
|
||||
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
|
||||
from tonutils.exceptions import ProviderResponseError
|
||||
|
||||
from pyfragment.types import TransactionError, WalletError, WalletInfo
|
||||
from pyfragment.types.constants import (
|
||||
MIN_TON_BALANCE,
|
||||
MIN_USDT_BALANCE,
|
||||
USDT_TON_MASTER_ADDRESS,
|
||||
WALLET_CLASSES,
|
||||
PaymentMethod,
|
||||
)
|
||||
from pyfragment.utils.decoder import clean_decode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
async def _get_usdt_balance(ton: Any, wallet_address: str) -> float:
|
||||
"""Return wallet USDT balance via tonutils jetton get-methods."""
|
||||
try:
|
||||
jetton_wallet_address = await get_wallet_address_get_method(
|
||||
client=ton,
|
||||
address=USDT_TON_MASTER_ADDRESS,
|
||||
owner_address=wallet_address,
|
||||
)
|
||||
wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address)
|
||||
raw_balance = int(wallet_data[0]) if wallet_data else 0
|
||||
return float(raw_balance) / 1_000_000.0
|
||||
except ProviderResponseError as exc:
|
||||
# No jetton wallet deployed yet -> effectively zero USDT balance.
|
||||
if exc.code == 404:
|
||||
return 0.0
|
||||
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
||||
except Exception as exc:
|
||||
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
||||
|
||||
|
||||
async def _check_ton_payment_balance(
|
||||
balance_ton: float,
|
||||
amount_ton: float,
|
||||
required_payment_amount: float | None,
|
||||
) -> None:
|
||||
"""Validate balance requirements for TON payment method."""
|
||||
tx_price_ton = amount_ton
|
||||
if required_payment_amount is not None and required_payment_amount > 0:
|
||||
tx_price_ton = max(tx_price_ton, required_payment_amount)
|
||||
|
||||
required_ton = max(tx_price_ton, MIN_TON_BALANCE)
|
||||
if balance_ton < required_ton:
|
||||
raise WalletError(
|
||||
WalletError.LOW_TON_BALANCE.format(
|
||||
balance=balance_ton,
|
||||
required=required_ton,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _check_usdt_payment_balance(
|
||||
balance_ton: float,
|
||||
required_payment_amount: float | None,
|
||||
ton: Any,
|
||||
wallet_address: str,
|
||||
) -> None:
|
||||
"""Validate balance requirements for USDT payment method."""
|
||||
# USDT payment still needs TON for network fees.
|
||||
if balance_ton < 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:
|
||||
raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt))
|
||||
|
||||
|
||||
async def process_transaction(
|
||||
client: FragmentClient,
|
||||
transaction_data: dict[str, Any],
|
||||
payment_method: PaymentMethod = "ton",
|
||||
required_payment_amount: float | None = None,
|
||||
) -> str:
|
||||
"""Sign and broadcast a Fragment transaction to the TON network.
|
||||
|
||||
Validates the payload structure, checks the wallet balance, decodes the
|
||||
on-chain comment, and calls ``wallet.transfer``.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
transaction_data: Raw transaction dict from ``execute_transaction_request``.
|
||||
payment_method: Payment currency — ``"ton"`` or ``"usdt_ton"``.
|
||||
required_payment_amount: Optional price from init*Request response.
|
||||
|
||||
Returns:
|
||||
Normalised transaction hash string.
|
||||
|
||||
Raises:
|
||||
TransactionError: If the payload is malformed or the broadcast fails.
|
||||
WalletError: If the wallet balance is too low or cannot be fetched.
|
||||
"""
|
||||
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
|
||||
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
||||
|
||||
message = transaction_data["transaction"]["messages"][0]
|
||||
amount_ton = int(message["amount"]) / 1_000_000_000
|
||||
|
||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
|
||||
# Check balance covers selected payment flow requirements.
|
||||
try:
|
||||
await wallet.refresh()
|
||||
balance_ton = wallet.balance / 1_000_000_000
|
||||
wallet_address = wallet.address.to_str(False, False)
|
||||
if payment_method == "ton":
|
||||
await _check_ton_payment_balance(
|
||||
balance_ton,
|
||||
amount_ton,
|
||||
required_payment_amount,
|
||||
)
|
||||
else:
|
||||
await _check_usdt_payment_balance(
|
||||
balance_ton,
|
||||
required_payment_amount,
|
||||
ton,
|
||||
wallet_address,
|
||||
)
|
||||
except WalletError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
||||
|
||||
try:
|
||||
raw_payload = str(message.get("payload", ""))
|
||||
payload = clean_decode(raw_payload)
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
result = await wallet.transfer(
|
||||
destination=message["address"],
|
||||
amount=int(message["amount"]), # nanotons, not TON
|
||||
body=payload,
|
||||
)
|
||||
return str(result.normalized_hash)
|
||||
except ProviderResponseError as exc:
|
||||
if exc.code == 429 and attempt == 0:
|
||||
await asyncio.sleep(1 + random.uniform(0, 0.5))
|
||||
continue
|
||||
if exc.code == 406 and "seqno" in str(exc).lower():
|
||||
# Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry
|
||||
if attempt < 2:
|
||||
await asyncio.sleep(2 + random.uniform(0, 1))
|
||||
continue
|
||||
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
|
||||
raise
|
||||
except (WalletError, TransactionError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
cause: BaseException | None = exc
|
||||
while cause is not None:
|
||||
if isinstance(cause, ssl.SSLError):
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
|
||||
cause = cause.__cause__ or cause.__context__
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
||||
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
|
||||
|
||||
|
||||
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
|
||||
"""Fetch wallet address, public key, and state-init for the Fragment API.
|
||||
|
||||
Fragment requires account info to build each transaction payload. The
|
||||
returned dict is JSON-serialised and passed as the ``account`` field in
|
||||
``getBuy*Link`` / ``get*Link`` requests.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
|
||||
Returns:
|
||||
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
|
||||
|
||||
Raises:
|
||||
WalletError: If account info cannot be retrieved.
|
||||
"""
|
||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||
try:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
boc = wallet.state_init.serialize().to_boc()
|
||||
return {
|
||||
"address": wallet.address.to_str(False, False),
|
||||
"publicKey": pub_key.as_hex,
|
||||
"chain": "-239",
|
||||
"walletStateInit": base64.b64encode(boc).decode(),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
|
||||
|
||||
|
||||
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
|
||||
"""Return the address, state and balance of the TON wallet.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
|
||||
Returns:
|
||||
:class:`WalletInfo` with ``address``, ``state``, ``balance`` in TON,
|
||||
and ``usdt_balance`` in USDT.
|
||||
|
||||
Raises:
|
||||
WalletError: If the wallet state cannot be fetched.
|
||||
"""
|
||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||
try:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
await wallet.refresh()
|
||||
wallet_address = wallet.address.to_str(False, False)
|
||||
usdt_balance = await _get_usdt_balance(ton, wallet_address)
|
||||
return WalletInfo(
|
||||
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
|
||||
state=wallet.state.value,
|
||||
ton_balance=round(wallet.balance / 1_000_000_000, 4),
|
||||
usdt_balance=round(usdt_balance, 4),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
|
||||
+27
-35
@@ -4,64 +4,45 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "pyfragment"
|
||||
version = "2026.2.2"
|
||||
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."
|
||||
version = "2026.3.3"
|
||||
description = "Async Python client for the Fragment.com marketplace API."
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.11"
|
||||
authors = [{ name = "bohd4nx" }]
|
||||
keywords = [
|
||||
"fragment",
|
||||
"fragment-api",
|
||||
"telegram",
|
||||
"telegram-api",
|
||||
"telegram-stars",
|
||||
"telegram-premium",
|
||||
"telegram-giveaway",
|
||||
"telegram-ads",
|
||||
"ton",
|
||||
"ton-blockchain",
|
||||
"tonkeeper",
|
||||
"tonapi",
|
||||
"anonymous-numbers",
|
||||
"username-auctions",
|
||||
"gift-marketplace",
|
||||
"crypto-payments",
|
||||
"nft-marketplace",
|
||||
"web3",
|
||||
"python-client",
|
||||
"typed",
|
||||
"asyncio",
|
||||
]
|
||||
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",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"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", "rookiepy>=0.5.6", "tonutils>=2.0.1"]
|
||||
dependencies = ["curl-cffi>=0.14.0", "tonutils>=2.0.1"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
|
||||
browser = ["rookiepy>=0.5.6"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/bohd4nx/pyfragment"
|
||||
Repository = "https://github.com/bohd4nx/pyfragment"
|
||||
Documentation = "https://bohd4nx.gitbook.io/pyfragment/"
|
||||
Issues = "https://github.com/bohd4nx/pyfragment/issues"
|
||||
Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
|
||||
|
||||
@@ -76,20 +57,31 @@ addopts = "-v --tb=short"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 128
|
||||
target-version = "py312"
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
|
||||
select = ["E", "F", "W", "I", "UP"]
|
||||
# E501 — line too long (covered by line-length above)
|
||||
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
|
||||
ignore = ["E501", "UP017"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["E402"]
|
||||
"systests/*" = ["E402"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
exclude = ["^systests/", "^examples/"]
|
||||
exclude = ["^examples/", "^logs/", "^tests/"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"rookiepy",
|
||||
"ton_core",
|
||||
"tonutils",
|
||||
"tonutils.*",
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
# rookiepy = None assignment is valid when the package is installed (type: ModuleType)
|
||||
# but mypy on CI (without rookiepy) sees it as Any and flags the comment as unused.
|
||||
module = "pyfragment.services.cookies.service"
|
||||
warn_unused_ignores = false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for clean_decode() — TON BOC payload decoding."""
|
||||
"""Decode Fragment BOC payloads so comments become text and structured messages stay raw."""
|
||||
|
||||
import base64
|
||||
import re
|
||||
@@ -7,8 +7,8 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
from ton_core import Cell
|
||||
|
||||
from pyfragment.types import ParseError
|
||||
from pyfragment.utils.decoder import clean_decode
|
||||
from pyfragment import ParseError
|
||||
from pyfragment.services.tonapi.transaction import clean_decode
|
||||
|
||||
PAYLOAD_CASES = [
|
||||
pytest.param(
|
||||
@@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None:
|
||||
raw = b"\xfb\xef\xff\x00"
|
||||
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
|
||||
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
|
||||
result = clean_decode(payload)
|
||||
|
||||
mocked.assert_called_once_with(raw)
|
||||
@@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None:
|
||||
return _FakeSlice()
|
||||
|
||||
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
||||
with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=_FakeCell()):
|
||||
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()):
|
||||
parsed = clean_decode(payload)
|
||||
|
||||
assert parsed == "Telegram Premium Ref#abc"
|
||||
@@ -126,7 +126,7 @@ def test_clean_decode_returns_cell_for_binary_payload() -> None:
|
||||
|
||||
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
||||
fake_cell: object = _FakeCell()
|
||||
with patch("pyfragment.utils.decoder.Cell.one_from_boc", return_value=fake_cell):
|
||||
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell):
|
||||
parsed = clean_decode(payload)
|
||||
|
||||
assert parsed is fake_cell
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Unit tests for FragmentClient — initialization, validation, and cookie parsing."""
|
||||
"""Validate FragmentClient setup, cookie parsing, and wallet version checks."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import ConfigurationError, CookieError
|
||||
from pyfragment import ConfigurationError, CookieError, FragmentClient
|
||||
from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID
|
||||
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||
|
||||
# Client init tests
|
||||
@@ -16,6 +16,30 @@ def test_valid_init() -> None:
|
||||
assert client.seed == VALID_SEED.strip()
|
||||
assert client.api_key == VALID_API_KEY
|
||||
assert client.wallet_version == "V5R1"
|
||||
assert client.api_provider == "tonapi"
|
||||
|
||||
|
||||
# API provider tests
|
||||
|
||||
|
||||
def test_api_provider_default_is_tonapi() -> None:
|
||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
assert client.api_provider == "tonapi"
|
||||
|
||||
|
||||
def test_api_provider_toncenter() -> None:
|
||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="toncenter")
|
||||
assert client.api_provider == "toncenter"
|
||||
|
||||
|
||||
def test_api_provider_is_case_insensitive() -> None:
|
||||
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="TONAPI")
|
||||
assert client.api_provider == "tonapi"
|
||||
|
||||
|
||||
def test_unsupported_api_provider_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="infura")
|
||||
|
||||
|
||||
# Wallet version tests
|
||||
@@ -44,6 +68,11 @@ def test_missing_seed_raises() -> None:
|
||||
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_both_seed_and_api_key_missing_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed="", api_key="", cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_whitespace_only_seed_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
@@ -56,7 +85,7 @@ def test_invalid_mnemonic_length_raises() -> None:
|
||||
|
||||
|
||||
def test_valid_mnemonic_lengths() -> None:
|
||||
for length in (12, 18, 24):
|
||||
for length in sorted(MNEMONIC_WORD_COUNTS_VALID):
|
||||
seed = " ".join(["abandon"] * (length - 1) + ["about"])
|
||||
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
assert len(client.seed.split()) == length
|
||||
@@ -70,11 +99,6 @@ def test_missing_api_key_raises() -> None:
|
||||
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
def test_short_api_key_raises() -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
|
||||
|
||||
|
||||
# Cookie validation tests
|
||||
|
||||
|
||||
@@ -110,6 +134,7 @@ def test_repr() -> None:
|
||||
r = repr(client)
|
||||
assert "FragmentClient" in r
|
||||
assert "V5R1" in r
|
||||
assert "tonapi" in r
|
||||
assert "4 keys" in r
|
||||
|
||||
|
||||
|
||||
+25
-23
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for process_transaction() — balance validation and broadcast retry logic."""
|
||||
"""Exercise transaction signing, balance checks, and retry behavior for GRAM (ex TON) and USDT flows."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
@@ -7,8 +7,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from tonutils.exceptions import ProviderResponseError
|
||||
|
||||
from pyfragment.types import TransactionError, WalletError
|
||||
from pyfragment.utils.wallet import process_transaction
|
||||
from pyfragment import TransactionError, WalletError
|
||||
from pyfragment.enums import PaymentMethod
|
||||
from pyfragment.services.tonapi.transaction import process_transaction
|
||||
from tests.shared import VALID_SEED
|
||||
|
||||
|
||||
@@ -21,7 +22,7 @@ TRANSACTION_DATA = {
|
||||
"messages": [
|
||||
{
|
||||
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
|
||||
"amount": "500000000", # 0.5 TON
|
||||
"amount": "500000000", # 0.5 GRAM
|
||||
"payload": "",
|
||||
}
|
||||
]
|
||||
@@ -47,12 +48,13 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
|
||||
|
||||
@contextmanager
|
||||
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
||||
mock_ton_ctx = MagicMock()
|
||||
mock_ton_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_ton_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
with (
|
||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
||||
patch("pyfragment.services.tonapi.transaction._make_ton_client", return_value=mock_ton_ctx),
|
||||
patch("pyfragment.services.tonapi.transaction.WALLET_CLASSES") as mock_classes,
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
|
||||
yield
|
||||
|
||||
@@ -62,8 +64,8 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sufficient_balance_broadcasts() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold
|
||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 GRAM, above threshold
|
||||
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
|
||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert result == "abc123"
|
||||
wallet.transfer.assert_called_once()
|
||||
@@ -71,7 +73,7 @@ async def test_sufficient_balance_broadcasts() -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_insufficient_balance_raises() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, below threshold
|
||||
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 GRAM, below threshold
|
||||
with _patch_wallet(wallet):
|
||||
with pytest.raises(WalletError, match="required"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
@@ -81,14 +83,14 @@ async def test_insufficient_balance_raises() -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_minimum_balance_broadcasts() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
|
||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
|
||||
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
|
||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert result == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_nanoton_below_minimum_raises() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanoton below transaction amount threshold
|
||||
wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanogram below transaction amount threshold
|
||||
with _patch_wallet(wallet):
|
||||
with pytest.raises(WalletError, match="required"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
@@ -123,7 +125,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
|
||||
async def test_rate_limit_retries_and_succeeds() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
|
||||
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""):
|
||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert result == "abc123"
|
||||
assert wallet.transfer.call_count == 2
|
||||
@@ -134,18 +136,18 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||
err = _provider_error(406, "Duplicate msg_seqno")
|
||||
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
|
||||
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""):
|
||||
with pytest.raises(TransactionError, match="seqno"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert wallet.transfer.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE
|
||||
with _patch_wallet(wallet), patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=100.0)):
|
||||
with pytest.raises(WalletError, match="Insufficient TON balance"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
|
||||
async def test_usdt_payment_requires_min_gram_gas_reserve() -> None:
|
||||
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 GRAM below MIN_GRAM_BALANCE
|
||||
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=100.0)):
|
||||
with pytest.raises(WalletError, match="Insufficient GRAM"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method=PaymentMethod.USDT_GRAM)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -166,13 +168,13 @@ async def test_usdt_payment_checks_usdt_balance() -> None:
|
||||
|
||||
with (
|
||||
_patch_wallet(wallet),
|
||||
patch("pyfragment.utils.wallet.clean_decode", return_value=""),
|
||||
patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=5.0)),
|
||||
patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""),
|
||||
patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=5.0)),
|
||||
):
|
||||
with pytest.raises(WalletError, match="Insufficient USDT balance"):
|
||||
await process_transaction(
|
||||
_make_client(),
|
||||
transaction,
|
||||
payment_method="usdt_ton",
|
||||
payment_method=PaymentMethod.USDT_GRAM,
|
||||
required_payment_amount=12.5,
|
||||
)
|
||||
|
||||
+131
-15
@@ -1,14 +1,21 @@
|
||||
"""Unit tests for Stars methods — purchase_stars and giveaway_stars."""
|
||||
"""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.methods.purchase_stars")
|
||||
_giveaway_stars_mod = importlib.import_module("pyfragment.methods.giveaway_stars")
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError
|
||||
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 pyfragment.core.constants import (
|
||||
STARS_GIVEAWAY_MAX,
|
||||
STARS_GIVEAWAY_MIN,
|
||||
STARS_PURCHASE_MAX,
|
||||
STARS_PURCHASE_MIN,
|
||||
STARS_WINNERS_MAX,
|
||||
STARS_WINNERS_MIN,
|
||||
)
|
||||
from pyfragment.enums import PaymentMethod
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
# Stars purchase validation tests
|
||||
@@ -17,13 +24,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSAC
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_stars("@user", amount=49)
|
||||
await client.purchase_stars("@user", amount=STARS_PURCHASE_MIN - 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_stars("@user", amount=1_000_001)
|
||||
await client.purchase_stars("@user", amount=STARS_PURCHASE_MAX + 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -80,7 +87,7 @@ async def test_purchase_stars_passes_payment_method(client: FragmentClient) -> N
|
||||
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_purchase_stars_mod, "process_transaction", proc_mock),
|
||||
):
|
||||
await client.purchase_stars("@user", amount=500, payment_method="usdt_ton")
|
||||
await client.purchase_stars("@user", amount=500, payment_method=PaymentMethod.USDT_GRAM)
|
||||
|
||||
init_call = call_mock.await_args_list[2]
|
||||
assert init_call.args[0] == "initBuyStarsRequest"
|
||||
@@ -115,25 +122,25 @@ async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_stars("@channel", winners=0, amount=500)
|
||||
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN - 1, amount=STARS_GIVEAWAY_MIN)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_stars("@channel", winners=6, amount=500)
|
||||
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MAX + 1, amount=STARS_GIVEAWAY_MIN)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_stars("@channel", winners=1, amount=499)
|
||||
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MIN - 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_stars("@channel", winners=1, amount=1_000_001)
|
||||
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MAX + 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -166,6 +173,8 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{},
|
||||
{},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
@@ -188,6 +197,8 @@ 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,
|
||||
]
|
||||
@@ -198,9 +209,9 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N
|
||||
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_giveaway_stars_mod, "process_transaction", proc_mock),
|
||||
):
|
||||
await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method="usdt_ton")
|
||||
await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method=PaymentMethod.USDT_GRAM)
|
||||
|
||||
init_call = call_mock.await_args_list[1]
|
||||
init_call = call_mock.await_args_list[3]
|
||||
assert init_call.args[0] == "initGiveawayStarsRequest"
|
||||
assert init_call.args[1]["payment_method"] == "usdt_ton"
|
||||
assert proc_mock.await_args is not None
|
||||
@@ -225,3 +236,108 @@ async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.giveaway_stars("@ghost", winners=1, amount=500)
|
||||
|
||||
|
||||
# Stars purchase — error branches
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_not_a_user_raises(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"error": "Please enter a username assigned to a user."})):
|
||||
with pytest.raises(UserNotFoundError, match="does not belong"):
|
||||
await client.purchase_stars("@channel", amount=500)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_missing_req_id_raises(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{}, # updateStarsBuyState
|
||||
{"amount": "0.1"}, # initBuyStarsRequest — no req_id
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
from pyfragment.exceptions import FragmentAPIError
|
||||
|
||||
with pytest.raises(FragmentAPIError):
|
||||
await client.purchase_stars("@user", amount=500)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_stars_need_verify_raises(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{}, # updateStarsBuyState
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
{"need_verify": True}, # getBuyStarsLink
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
from pyfragment.exceptions import VerificationError
|
||||
|
||||
with pytest.raises(VerificationError):
|
||||
await client.purchase_stars("@user", amount=500)
|
||||
|
||||
|
||||
# Stars giveaway — error branches
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_missing_req_id_raises(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{}, # updateStarsGiveawayState
|
||||
{}, # updateStarsGiveawayPrices
|
||||
{"amount": "0.1"}, # initGiveawayStarsRequest — no req_id
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
from pyfragment.exceptions import FragmentAPIError
|
||||
|
||||
with pytest.raises(FragmentAPIError):
|
||||
await client.giveaway_stars("@channel", winners=3, amount=1000)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_stars_need_verify_raises(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{}, # updateStarsGiveawayState
|
||||
{}, # updateStarsGiveawayPrices
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
{"need_verify": True}, # getGiveawayStarsLink
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
from pyfragment.exceptions import VerificationError
|
||||
|
||||
with pytest.raises(VerificationError):
|
||||
await client.giveaway_stars("@channel", winners=3, amount=1000)
|
||||
|
||||
+141
-12
@@ -1,14 +1,15 @@
|
||||
"""Unit tests for Premium methods — purchase_premium and giveaway_premium."""
|
||||
"""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.methods.purchase_premium")
|
||||
_giveaway_premium_mod = importlib.import_module("pyfragment.methods.giveaway_premium")
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError
|
||||
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 pyfragment.core.constants import PREMIUM_MONTHS_VALID, PREMIUM_WINNERS_MAX, PREMIUM_WINNERS_MIN
|
||||
from pyfragment.enums import PaymentMethod
|
||||
from pyfragment.exceptions import AlreadySubscribedError
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
# Premium purchase validation tests
|
||||
@@ -23,7 +24,7 @@ async def test_purchase_premium_invalid_months(client: FragmentClient) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.purchase_premium("@user", months=0)
|
||||
await client.purchase_premium("@user", months=min(PREMIUM_MONTHS_VALID) - 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -77,7 +78,7 @@ async def test_purchase_premium_passes_payment_method(client: FragmentClient) ->
|
||||
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_purchase_premium_mod, "process_transaction", proc_mock),
|
||||
):
|
||||
await client.purchase_premium("@user", months=6, payment_method="usdt_ton")
|
||||
await client.purchase_premium("@user", months=6, payment_method=PaymentMethod.USDT_GRAM)
|
||||
|
||||
init_call = call_mock.await_args_list[2]
|
||||
assert init_call.args[0] == "initGiftPremiumRequest"
|
||||
@@ -86,6 +87,25 @@ async def test_purchase_premium_passes_payment_method(client: FragmentClient) ->
|
||||
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_already_subscribed_raises(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{}, # updatePremiumState
|
||||
{"error": "This account is already subscribed to Telegram Premium."},
|
||||
]
|
||||
),
|
||||
),
|
||||
):
|
||||
with pytest.raises(AlreadySubscribedError):
|
||||
await client.purchase_premium("@user", months=6)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"])
|
||||
async def test_purchase_premium_accepts_query_formats(client: FragmentClient, query: str) -> None:
|
||||
@@ -112,13 +132,13 @@ async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_premium("@channel", winners=0, months=3)
|
||||
await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MIN - 1, months=3)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.giveaway_premium("@channel", winners=24_001, months=3)
|
||||
await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MAX + 1, months=3)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -151,6 +171,8 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{},
|
||||
{},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
FAKE_TRANSACTION,
|
||||
]
|
||||
@@ -173,6 +195,8 @@ 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,
|
||||
]
|
||||
@@ -183,9 +207,9 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) ->
|
||||
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_giveaway_premium_mod, "process_transaction", proc_mock),
|
||||
):
|
||||
await client.giveaway_premium("@channel", winners=10, months=6, payment_method="usdt_ton")
|
||||
await client.giveaway_premium("@channel", winners=10, months=6, payment_method=PaymentMethod.USDT_GRAM)
|
||||
|
||||
init_call = call_mock.await_args_list[1]
|
||||
init_call = call_mock.await_args_list[3]
|
||||
assert init_call.args[0] == "initGiveawayPremiumRequest"
|
||||
assert init_call.args[1]["payment_method"] == "usdt_ton"
|
||||
assert proc_mock.await_args is not None
|
||||
@@ -210,3 +234,108 @@ async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> Non
|
||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.giveaway_premium("@ghost", winners=1, months=3)
|
||||
|
||||
|
||||
# Premium purchase — error branches
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_not_a_user_raises(client: FragmentClient) -> None:
|
||||
with patch.object(client, "call", AsyncMock(return_value={"error": "Please enter a username assigned to a user."})):
|
||||
with pytest.raises(UserNotFoundError, match="does not belong"):
|
||||
await client.purchase_premium("@channel", months=3)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_missing_req_id_raises(client: FragmentClient) -> None:
|
||||
from pyfragment.exceptions import FragmentAPIError
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{}, # updatePremiumState
|
||||
{"amount": "0.1"}, # initGiftPremiumRequest — no req_id
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
with pytest.raises(FragmentAPIError):
|
||||
await client.purchase_premium("@user", months=3)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_purchase_premium_need_verify_raises(client: FragmentClient) -> None:
|
||||
from pyfragment.exceptions import VerificationError
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{}, # updatePremiumState
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
{"need_verify": True}, # getGiftPremiumLink
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
with pytest.raises(VerificationError):
|
||||
await client.purchase_premium("@user", months=3)
|
||||
|
||||
|
||||
# Premium giveaway — error branches
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_premium_missing_req_id_raises(client: FragmentClient) -> None:
|
||||
from pyfragment.exceptions import FragmentAPIError
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{}, # updatePremiumGiveawayState
|
||||
{}, # updatePremiumGiveawayPrices
|
||||
{"amount": "0.1"}, # initGiveawayPremiumRequest — no req_id
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
with pytest.raises(FragmentAPIError):
|
||||
await client.giveaway_premium("@channel", winners=10, months=3)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giveaway_premium_need_verify_raises(client: FragmentClient) -> None:
|
||||
from pyfragment.exceptions import VerificationError
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{}, # updatePremiumGiveawayState
|
||||
{}, # updatePremiumGiveawayPrices
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
{"need_verify": True}, # getGiveawayPremiumLink
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
with pytest.raises(VerificationError):
|
||||
await client.giveaway_premium("@channel", winners=10, months=3)
|
||||
|
||||
+66
-19
@@ -1,41 +1,40 @@
|
||||
"""Unit tests for topup_ton — TON Ads balance top-up."""
|
||||
"""Cover GRAM (ex 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.methods.topup_ton")
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError
|
||||
import pyfragment.domains.ads.tonup as _topup_gram_mod
|
||||
from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
|
||||
from pyfragment.core.constants import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
# Topup TON validation tests
|
||||
# Topup GRAM (ex TON) validation tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_amount_zero(client: FragmentClient) -> None:
|
||||
async def test_topup_gram_amount_zero(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.topup_ton("@user", amount=0)
|
||||
await client.topup_gram("@user", amount=GRAM_TOPUP_MIN - 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_amount_too_high(client: FragmentClient) -> None:
|
||||
async def test_topup_gram_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.topup_ton("@user", amount=1_000_000_001)
|
||||
await client.topup_gram("@user", amount=GRAM_TOPUP_MAX + 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_float_amount(client: FragmentClient) -> None:
|
||||
async def test_topup_gram_float_amount(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type]
|
||||
await client.topup_gram("@user", amount=1.5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# Topup TON mocked tests
|
||||
# Topup GRAM (ex TON) mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_success(client: FragmentClient) -> None:
|
||||
async def test_topup_gram_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
@@ -49,10 +48,10 @@ async def test_topup_ton_success(client: FragmentClient) -> None:
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch.object(_topup_gram_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
):
|
||||
result = await client.topup_ton("@user", amount=10)
|
||||
result = await client.topup_gram("@user", amount=10)
|
||||
|
||||
assert isinstance(result, AdsTopupResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
@@ -61,7 +60,7 @@ async def test_topup_ton_success(client: FragmentClient) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
|
||||
async def test_topup_gram_user_not_found(client: FragmentClient) -> None:
|
||||
with patch.object(
|
||||
client,
|
||||
"call",
|
||||
@@ -73,4 +72,52 @@ async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
|
||||
),
|
||||
):
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await client.topup_ton("@ghost", amount=10)
|
||||
await client.topup_gram("@ghost", amount=10)
|
||||
|
||||
|
||||
# topup_gram — error branches
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_gram_missing_req_id_raises(client: FragmentClient) -> None:
|
||||
from pyfragment.exceptions import FragmentAPIError
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsTopupState
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{"amount": "0.1"}, # initAdsTopupRequest — no req_id
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
with pytest.raises(FragmentAPIError):
|
||||
await client.topup_gram("@user", amount=10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_gram_need_verify_raises(client: FragmentClient) -> None:
|
||||
from pyfragment.exceptions import VerificationError
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsTopupState
|
||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
{"need_verify": True}, # getAdsTopupLink
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
with pytest.raises(VerificationError):
|
||||
await client.topup_gram("@user", amount=10)
|
||||
|
||||
+108
-14
@@ -1,30 +1,30 @@
|
||||
"""Unit tests for get_wallet() — wallet address/state with separate TON and USDT balances."""
|
||||
"""Verify wallet inspection returns friendly GRAM (ex TON) and USDT balances from Tonapi."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient, WalletInfo
|
||||
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
|
||||
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOGRAM
|
||||
|
||||
# Wallet mocked tests (TON and USDT balances are returned separately)
|
||||
# Wallet mocked tests (GRAM and USDT balances are returned separately)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
||||
mock_wallet = MagicMock()
|
||||
mock_wallet.refresh = AsyncMock()
|
||||
mock_wallet.balance = FAKE_BALANCE_NANOTON
|
||||
mock_wallet.balance = FAKE_BALANCE_NANOGRAM
|
||||
mock_wallet.state = MagicMock(value="active")
|
||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||
|
||||
with (
|
||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
||||
patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=12.3456)),
|
||||
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
|
||||
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
|
||||
patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=12.3456)),
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager
|
||||
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
|
||||
|
||||
result = await client.get_wallet()
|
||||
@@ -32,7 +32,7 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
||||
assert isinstance(result, WalletInfo)
|
||||
assert result.address == FAKE_ADDRESS
|
||||
assert result.state == "active"
|
||||
assert result.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
|
||||
assert result.gram_balance == round(FAKE_BALANCE_NANOGRAM / 1_000_000_000, 4)
|
||||
assert result.usdt_balance == 12.3456
|
||||
|
||||
|
||||
@@ -45,16 +45,110 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
|
||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||
|
||||
with (
|
||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
||||
patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=0.0)),
|
||||
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
|
||||
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
|
||||
patch("pyfragment.services.tonapi.account.get_usdt_balance", AsyncMock(return_value=0.0)),
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) # _make_ton_client returns context manager
|
||||
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
|
||||
|
||||
result = await client.get_wallet()
|
||||
|
||||
assert result.ton_balance == 0.0
|
||||
assert result.gram_balance == 0.0
|
||||
assert result.usdt_balance == 0.0
|
||||
assert result.state == "uninit"
|
||||
|
||||
|
||||
# _make_ton_client — provider selection
|
||||
|
||||
|
||||
def test_make_ton_client_uses_toncenter_for_toncenter_provider(client: FragmentClient) -> None:
|
||||
from tonutils.clients import ToncenterClient
|
||||
|
||||
from pyfragment.enums import ApiProvider
|
||||
from pyfragment.services.tonapi.account import _make_ton_client
|
||||
|
||||
client.api_provider = ApiProvider.TONCENTER
|
||||
result = _make_ton_client(client)
|
||||
assert isinstance(result, ToncenterClient)
|
||||
|
||||
|
||||
def test_make_ton_client_uses_tonapi_for_tonapi_provider(client: FragmentClient) -> None:
|
||||
from tonutils.clients import TonapiClient
|
||||
|
||||
from pyfragment.enums import ApiProvider
|
||||
from pyfragment.services.tonapi.account import _make_ton_client
|
||||
|
||||
client.api_provider = ApiProvider.TONAPI
|
||||
result = _make_ton_client(client)
|
||||
assert isinstance(result, TonapiClient)
|
||||
|
||||
|
||||
# get_usdt_balance — error paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_usdt_balance_non_404_provider_error_raises() -> None:
|
||||
from tonutils.exceptions import ProviderResponseError
|
||||
|
||||
from pyfragment import WalletError
|
||||
from pyfragment.services.tonapi.account import get_usdt_balance
|
||||
|
||||
ton = MagicMock()
|
||||
err = ProviderResponseError(code=500, message="server error", endpoint="api.tonapi.io")
|
||||
|
||||
with patch("pyfragment.services.tonapi.account.get_wallet_address_get_method", AsyncMock(side_effect=err)):
|
||||
with pytest.raises(WalletError):
|
||||
await get_usdt_balance(ton, "0:abc")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_usdt_balance_generic_exception_raises() -> None:
|
||||
from pyfragment import WalletError
|
||||
from pyfragment.services.tonapi.account import get_usdt_balance
|
||||
|
||||
ton = MagicMock()
|
||||
|
||||
with patch(
|
||||
"pyfragment.services.tonapi.account.get_wallet_address_get_method", AsyncMock(side_effect=RuntimeError("timeout"))
|
||||
):
|
||||
with pytest.raises(WalletError):
|
||||
await get_usdt_balance(ton, "0:abc")
|
||||
|
||||
|
||||
# get_account_info / get_wallet_info — exception paths
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_info_exception_raises_wallet_error(client: FragmentClient) -> None:
|
||||
from pyfragment import WalletError
|
||||
from pyfragment.services.tonapi.account import get_account_info
|
||||
|
||||
with (
|
||||
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
|
||||
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_classes["V5R1"].from_mnemonic.side_effect = RuntimeError("wallet init failed")
|
||||
|
||||
with pytest.raises(WalletError, match="account info"):
|
||||
await get_account_info(client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_wallet_info_exception_raises_wallet_error(client: FragmentClient) -> None:
|
||||
from pyfragment import WalletError
|
||||
from pyfragment.services.tonapi.account import get_wallet_info
|
||||
|
||||
with (
|
||||
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
|
||||
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_classes["V5R1"].from_mnemonic.side_effect = RuntimeError("key derivation failed")
|
||||
|
||||
with pytest.raises(WalletError, match="wallet info"):
|
||||
await get_wallet_info(client)
|
||||
|
||||
+14
-19
@@ -1,13 +1,12 @@
|
||||
"""Unit tests for FragmentClient.call() — raw Fragment API access."""
|
||||
"""Check raw Fragment API calls and transport error handling."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from curl_cffi.requests import AsyncSession, Response
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import FragmentPageError
|
||||
from pyfragment.utils.http import fragment_request
|
||||
from pyfragment import FragmentClient, FragmentPageError
|
||||
from pyfragment.core.transport import fragment_request
|
||||
from tests.shared import FAKE_HASH, FAKE_RESPONSE
|
||||
|
||||
# client.call() mocked tests
|
||||
@@ -16,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,10 +25,9 @@ async def test_call_returns_api_response(client: FragmentClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_default_page_url(client: FragmentClient) -> None:
|
||||
"""call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL)."""
|
||||
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")
|
||||
|
||||
@@ -38,12 +36,11 @@ async def test_call_default_page_url(client: FragmentClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_no_data(client: FragmentClient) -> None:
|
||||
"""call() with no extra data passes only the method field."""
|
||||
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,12 +50,11 @@ async def test_call_no_data(client: FragmentClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
||||
"""call() merges caller-supplied data with the method field."""
|
||||
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})
|
||||
|
||||
@@ -71,11 +67,10 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fragment_request_non_200_raises() -> None:
|
||||
"""fragment_request raises FragmentPageError on non-200 HTTP responses."""
|
||||
response = MagicMock(spec=httpx.Response)
|
||||
response = MagicMock(spec=Response)
|
||||
response.status_code = 429
|
||||
|
||||
session = AsyncMock(spec=httpx.AsyncClient)
|
||||
session = AsyncMock(spec=AsyncSession)
|
||||
session.post = AsyncMock(return_value=response)
|
||||
|
||||
with pytest.raises(FragmentPageError, match="429"):
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"""Unit tests for recharge_ads — self-service Telegram Ads recharge."""
|
||||
"""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.methods.recharge_ads")
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import AdsRechargeResult, ConfigurationError
|
||||
import pyfragment.domains.ads.recharge as _recharge_ads_mod
|
||||
from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient
|
||||
from pyfragment.core.constants import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
# recharge_ads validation tests
|
||||
@@ -16,13 +15,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANS
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0)
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MIN - 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001)
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MAX + 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -56,3 +55,49 @@ async def test_recharge_ads_success(client: FragmentClient) -> None:
|
||||
assert isinstance(result, AdsRechargeResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.amount == 10
|
||||
|
||||
|
||||
# recharge_ads — error branches
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_missing_req_id_raises(client: FragmentClient) -> None:
|
||||
from pyfragment.exceptions import FragmentAPIError
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsState
|
||||
{"amount": "0.1"}, # initAdsRechargeRequest — no req_id
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
with pytest.raises(FragmentAPIError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_need_verify_raises(client: FragmentClient) -> None:
|
||||
from pyfragment.exceptions import VerificationError
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
client,
|
||||
"call",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsState
|
||||
{"req_id": FAKE_REQ_ID},
|
||||
{"need_verify": True}, # getAdsRechargeLink
|
||||
]
|
||||
),
|
||||
),
|
||||
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
):
|
||||
with pytest.raises(VerificationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Unit tests for search_usernames — Fragment marketplace username search."""
|
||||
"""Verify username search parsing and query forwarding."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import UsernamesResult
|
||||
from pyfragment import FragmentClient, UsernamesResult
|
||||
|
||||
FAKE_HTML = """
|
||||
<tr class="tm-row-selectable">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Unit tests for search_numbers — Fragment marketplace number search."""
|
||||
"""Verify anonymous number search parsing and query forwarding."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import NumbersResult
|
||||
from pyfragment import FragmentClient, NumbersResult
|
||||
|
||||
FAKE_HTML = """
|
||||
<tr class="tm-row-selectable">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Unit tests for search_gifts — Fragment gifts marketplace search."""
|
||||
"""Verify gift search parsing and pagination handling."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import GiftsResult
|
||||
from pyfragment import FragmentClient, GiftsResult
|
||||
|
||||
FAKE_GIFTS_HTML = """
|
||||
<div class="tm-catalog-grid">
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Unit tests for get_cookies_from_browser() — browser cookie extraction helper."""
|
||||
"""Extract Fragment cookies from browser stores and validate required keys."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment.types import CookieError
|
||||
from pyfragment.types.constants import REQUIRED_COOKIE_KEYS
|
||||
from pyfragment.utils import get_cookies_from_browser
|
||||
from pyfragment import CookieError, get_cookies_from_browser
|
||||
from pyfragment.core.constants import REQUIRED_COOKIE_KEYS
|
||||
|
||||
FAKE_JAR = [
|
||||
{"name": "stel_ssid", "value": "abc123", "domain": "fragment.com", "expires": "2027-04-03T20:52:16.375Z"},
|
||||
@@ -23,7 +22,7 @@ def _mock_rookiepy(jar: list[dict[str, str]] | None = None) -> MagicMock:
|
||||
return mock
|
||||
|
||||
|
||||
PATCH = "pyfragment.utils.cookies.rookiepy"
|
||||
PATCH = "pyfragment.services.cookies.service.rookiepy"
|
||||
|
||||
|
||||
# unsupported browser tests
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Unit tests for init payment amount parsing."""
|
||||
"""Parse Fragment init responses to the payment amount the transaction should cover."""
|
||||
|
||||
from pyfragment.utils.html import parse_required_payment_amount
|
||||
from pyfragment.domains.payments import parse_required_payment_amount
|
||||
|
||||
|
||||
def test_parse_required_payment_amount_ton_uses_amount() -> None:
|
||||
|
||||
+8
-8
@@ -1,22 +1,23 @@
|
||||
"""Shared pytest fixtures for Fragment client tests."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
import pyfragment.methods.giveaway_premium # noqa: F401
|
||||
import pyfragment.methods.giveaway_stars # noqa: F401
|
||||
import pyfragment.methods.purchase_premium # noqa: F401
|
||||
import pyfragment.methods.purchase_stars # noqa: F401
|
||||
import pyfragment.methods.recharge_ads # noqa: F401
|
||||
import pyfragment.methods.topup_ton # noqa: F401
|
||||
import pyfragment.domains.ads.recharge # noqa: F401
|
||||
import pyfragment.domains.ads.tonup # noqa: F401
|
||||
import pyfragment.domains.giveaways.giveaway # noqa: F401
|
||||
import pyfragment.domains.purchases.purchase # noqa: F401
|
||||
import pyfragment.services.tonapi.account # noqa: F401
|
||||
import pyfragment.services.tonapi.transaction # noqa: F401
|
||||
from pyfragment import FragmentClient
|
||||
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cookies() -> dict[str, str]:
|
||||
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
|
||||
raw = os.environ.get("COOKIES_JSON")
|
||||
if not raw:
|
||||
pytest.skip("COOKIES_JSON env var not set")
|
||||
@@ -28,5 +29,4 @@ def cookies() -> dict[str, str]:
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> FragmentClient:
|
||||
"""Pre-built FragmentClient with dummy credentials."""
|
||||
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
+2
-6
@@ -1,7 +1,7 @@
|
||||
"""Shared test constants for the pyfragment test suite.
|
||||
|
||||
pyfragment is an async Python client for the Fragment API — a unified toolkit
|
||||
to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances,
|
||||
to manage Telegram assets: purchase Stars and Premium, top up GRAM (ex TON) and Ads balances,
|
||||
run giveaways, manage anonymous numbers, and explore the marketplace for usernames,
|
||||
numbers, and gifts.
|
||||
"""
|
||||
@@ -31,15 +31,11 @@ FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}}
|
||||
|
||||
# get_wallet()
|
||||
FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5"
|
||||
FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON
|
||||
FAKE_BALANCE_NANOGRAM: int = 1_500_000_000 # 1.5 GRAM (ex TON)
|
||||
|
||||
# recharge_ads
|
||||
FAKE_ADS_ACCOUNT: str = "@mychannel"
|
||||
|
||||
# Revenue withdrawals
|
||||
FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
FAKE_REVENUE_TX: str = "revenue_tx_abc123"
|
||||
|
||||
# Anonymous number
|
||||
FAKE_HTML_WITH_CODE: str = """
|
||||
<table>
|
||||
|
||||
Reference in New Issue
Block a user