mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-26 06:41:42 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7225b9d16 | |||
| 27ce28fd32 | |||
| b11b9f299b | |||
| 0a361c8c3a | |||
| f46551c905 | |||
| 2bd8cc177a | |||
| fd31342d03 | |||
| d78be012a3 | |||
| ef831d12a0 | |||
| 4539dc9803 | |||
| a9663d0b08 | |||
| 228fbfcd1c | |||
| dc9661134d | |||
| 9f11ccb3ee | |||
| 8f865f4911 | |||
| 3481769757 | |||
| b85b34f2b1 | |||
| d01c45d85b | |||
| 695430744a | |||
| afaa42776b | |||
| ed54d1f1a0 | |||
| 6aa7037380 | |||
| 5124af17ef | |||
| 66bcd22198 | |||
| 5c472b68a2 | |||
| b2d06a7f22 | |||
| 34ee31e299 | |||
| c1ff78c484 | |||
| f8ad801e0c | |||
| 6cb6e3fe05 | |||
| d4e3d3491b | |||
| 4a7131acdb | |||
| 546bcb337c | |||
| e9dd706fa6 | |||
| f8ac7f9594 | |||
| 3f5e0bc03f | |||
| 92384b96f0 | |||
| 516e69f820 | |||
| 2a6c5ef1f9 | |||
| 01a5befd87 | |||
| ae164d4fe7 | |||
| e7dc0e051e | |||
| 363e0719ab | |||
| b67415bcc2 | |||
| 5df95bea29 | |||
| 7412448f9c | |||
| 6dd9dcb5d4 | |||
| 311222d478 | |||
| 72918a6dd6 |
+11
-13
@@ -2,45 +2,43 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "**" ]
|
branches: ["**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "**" ]
|
branches: ["**"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: Lint & Format
|
name: Lint & Format
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7.0.1
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.11"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
- run: pip install ".[dev]"
|
- run: pip install ".[dev]"
|
||||||
|
|
||||||
- run: ruff check . && ruff format --check . && mypy pyfragment
|
- run: ruff check . && ruff format --check . && mypy pyfragment --explicit-package-bases
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests (Python ${{ matrix.python-version }})
|
name: Tests (Python ${{ matrix.python-version }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet
|
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7.0.1
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
|
||||||
- name: Install package and dev dependencies
|
- name: Install package and dev dependencies
|
||||||
run: pip install ".[dev]"
|
run: pip install ".[dev]"
|
||||||
|
|||||||
@@ -2,23 +2,21 @@ name: Publish
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: [ "CI" ]
|
workflows: ["CI"]
|
||||||
types: [ completed ]
|
types: [completed]
|
||||||
branches: [ master ]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version-check:
|
version-check:
|
||||||
name: Version Check
|
name: Version Check
|
||||||
if: github.event.workflow_run.conclusion == 'success'
|
if: github.event.workflow_run.conclusion == 'success'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.value }}
|
version: ${{ steps.version.outputs.value }}
|
||||||
is-new: ${{ steps.tag.outputs.is-new }}
|
is-new: ${{ steps.tag.outputs.is-new }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -42,31 +40,27 @@ jobs:
|
|||||||
needs: version-check
|
needs: version-check
|
||||||
if: needs.version-check.outputs.is-new == 'true'
|
if: needs.version-check.outputs.is-new == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7.0.1
|
||||||
|
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v8.1.0
|
- uses: astral-sh/setup-uv@v9.0.0
|
||||||
|
|
||||||
- run: uv build
|
- run: uv build
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v7.0.1
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist/*
|
path: dist/*
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish to PyPI
|
name: Publish to PyPI
|
||||||
needs: [ version-check, build ]
|
needs: [version-check, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
environment:
|
environment:
|
||||||
name: pypi
|
name: pypi
|
||||||
url: https://pypi.org/project/pyfragment/
|
url: https://pypi.org/project/pyfragment/
|
||||||
@@ -74,7 +68,7 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v8.0.1
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist
|
path: dist
|
||||||
@@ -83,19 +77,15 @@ jobs:
|
|||||||
|
|
||||||
release:
|
release:
|
||||||
name: GitHub Release
|
name: GitHub Release
|
||||||
needs: [ version-check, build ]
|
needs: [version-check, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v8.0.1
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist
|
path: dist
|
||||||
@@ -108,7 +98,7 @@ jobs:
|
|||||||
echo "$body" >> $GITHUB_OUTPUT
|
echo "$body" >> $GITHUB_OUTPUT
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- uses: softprops/action-gh-release@v3
|
- uses: softprops/action-gh-release@v3.0.2
|
||||||
with:
|
with:
|
||||||
tag_name: v${{ needs.version-check.outputs.version }}
|
tag_name: v${{ needs.version-check.outputs.version }}
|
||||||
name: v${{ needs.version-check.outputs.version }}
|
name: v${{ needs.version-check.outputs.version }}
|
||||||
|
|||||||
+28
-24
@@ -1,39 +1,43 @@
|
|||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.py[cod]
|
||||||
*.pyo
|
*$py.class
|
||||||
*.pyd
|
*.so
|
||||||
.Python
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual Environment
|
||||||
.venv/
|
|
||||||
venv/
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Environment variables
|
#docs
|
||||||
.env
|
*-docs/
|
||||||
|
|
||||||
# System files
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
uv.lock
|
||||||
# Testing & tooling artifacts
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
.mypy_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
.coverage
|
|
||||||
htmlcov/
|
|
||||||
systests/
|
|
||||||
|
|
||||||
# Build & distribution
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
*.egg-info/
|
|
||||||
|
|||||||
+158
-1
@@ -7,6 +7,147 @@ 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
|
||||||
|
|
||||||
|
- `payment_method` option (`"ton"` / `"usdt_ton"`) for:
|
||||||
|
- `purchase_stars()`
|
||||||
|
- `purchase_premium()`
|
||||||
|
- `giveaway_stars()`
|
||||||
|
- `giveaway_premium()`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added runtime validation for `payment_method` via `SUPPORTED_PAYMENT_METHODS` and `ConfigurationError.INVALID_PAYMENT_METHOD`
|
||||||
|
- Updated method docstrings to explicitly document recipient/channel formats:
|
||||||
|
- `@username` / `username` / `https://t.me/username`
|
||||||
|
- `get_wallet()` now returns balances as separate fields: `ton_balance` and `usdt_balance`
|
||||||
|
- Wallet/system test output now prints TON and USDT balances on separate lines
|
||||||
|
- Balance checks are now method-aware with explicit thresholds:
|
||||||
|
- `ton`: minimum TON balance threshold via `MIN_TON_BALANCE` (based on current 50 Stars purchase amount)
|
||||||
|
- `usdt_ton`: minimum USDT balance threshold via `MIN_USDT_BALANCE` (based on current 50 Stars purchase amount)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Extended stars and premium test suites to cover:
|
||||||
|
- invalid payment method
|
||||||
|
- payment method propagation to `init*Request` payloads
|
||||||
|
- accepted query formats (`@`, plain username, `t.me` link)
|
||||||
|
- Extended wallet tests to verify separate TON/USDT balance values in `WalletInfo`
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Simplified `README` usage example
|
||||||
|
|
||||||
## [2026.2.1] — 2026-05-03
|
## [2026.2.1] — 2026-05-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -28,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
|
- `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
|
```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", ...
|
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
|
||||||
client = FragmentClient(seed="...", api_key="...", cookies=result.cookies)
|
client = FragmentClient(seed="...", api_key="...", cookies=result.cookies)
|
||||||
print(result.expires) # ISO 8601 expiry of stel_ssid, or None for session cookies
|
print(result.expires) # ISO 8601 expiry of stel_ssid, or None for session cookies
|
||||||
@@ -48,37 +189,44 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
**Giveaways**
|
**Giveaways**
|
||||||
|
|
||||||
- `giveaway_stars(channel, winners, amount)` — Stars giveaway; 1–5 winners, 500–1 000 000 stars each
|
- `giveaway_stars(channel, winners, amount)` — Stars giveaway; 1–5 winners, 500–1 000 000 stars each
|
||||||
- `giveaway_premium(channel, winners, months)` — Premium giveaway; 1–24 000 winners, 3/6/12 months each
|
- `giveaway_premium(channel, winners, months)` — Premium giveaway; 1–24 000 winners, 3/6/12 months each
|
||||||
- `StarsGiveawayResult`, `PremiumGiveawayResult` result types
|
- `StarsGiveawayResult`, `PremiumGiveawayResult` result types
|
||||||
|
|
||||||
**Telegram Ads**
|
**Telegram Ads**
|
||||||
|
|
||||||
- `recharge_ads(account, amount)` — top up a Telegram Ads account; 1–1 000 000 000 TON
|
- `recharge_ads(account, amount)` — top up a Telegram Ads account; 1–1 000 000 000 TON
|
||||||
- `AdsRechargeResult` result type
|
- `AdsRechargeResult` result type
|
||||||
|
|
||||||
**Marketplace**
|
**Marketplace**
|
||||||
|
|
||||||
- `search_usernames(query?, sort?, filter?, offset_id?)` — search Fragment usernames; `sort`: `price_desc / price_asc / listed / ending`, `filter`: `auction / sale / sold`
|
- `search_usernames(query?, sort?, filter?, offset_id?)` — search Fragment usernames; `sort`: `price_desc / price_asc / listed / ending`, `filter`: `auction / sale / sold`
|
||||||
- `search_numbers(query?, sort?, filter?, offset_id?)` — search Fragment anonymous numbers; same `sort` / `filter` / pagination semantics
|
- `search_numbers(query?, sort?, filter?, offset_id?)` — search Fragment anonymous numbers; same `sort` / `filter` / pagination semantics
|
||||||
- `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search Fragment gifts; `attr` accepts `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}`
|
- `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search Fragment gifts; `attr` accepts `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}`
|
||||||
- `UsernamesResult`, `NumbersResult`, `GiftsResult` result types
|
- `UsernamesResult`, `NumbersResult`, `GiftsResult` result types
|
||||||
|
|
||||||
**Anonymous numbers**
|
**Anonymous numbers**
|
||||||
|
|
||||||
- `get_login_code(number)` — fetch the current pending login code
|
- `get_login_code(number)` — fetch the current pending login code
|
||||||
- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery
|
- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery
|
||||||
- `terminate_sessions(number)` — terminate all active Telegram sessions (two-step flow handled internally)
|
- `terminate_sessions(number)` — terminate all active Telegram sessions (two-step flow handled internally)
|
||||||
- `LoginCodeResult`, `TerminateSessionsResult` result types; `AnonymousNumberError` exception
|
- `LoginCodeResult`, `TerminateSessionsResult` result types; `AnonymousNumberError` exception
|
||||||
|
|
||||||
**Raw API**
|
**Raw API**
|
||||||
|
|
||||||
- `FragmentClient.call(method, data, *, page_url)` — raw request to any Fragment API method
|
- `FragmentClient.call(method, data, *, page_url)` — raw request to any Fragment API method
|
||||||
- `FRAGMENT_BASE_URL` constant — base URL shared across all page constants and headers
|
- `FRAGMENT_BASE_URL` constant — base URL shared across all page constants and headers
|
||||||
|
|
||||||
**Examples**
|
**Examples**
|
||||||
|
|
||||||
- `examples/client/` — `wallet_info.py` (wallet info), `raw_api_call.py` (raw API call)
|
- `examples/client/` — `wallet_info.py` (wallet info), `raw_api_call.py` (raw API call)
|
||||||
- `examples/numbers/` — `manage_number.py` (login code fetch, session termination)
|
- `examples/numbers/` — `manage_number.py` (login code fetch, session termination)
|
||||||
- `examples/auctions/` — `search_usernames.py`, `search_numbers.py`, `search_gifts.py` (marketplace search with pagination)
|
- `examples/auctions/` — `search_usernames.py`, `search_numbers.py`, `search_gifts.py` (marketplace search with pagination)
|
||||||
- `examples/purchase/` — `send_stars.py`, `send_premium.py`, `topup_ton_balance.py`, `run_stars_giveaway.py`, `run_premium_giveaway.py`, `recharge_ads_balance.py`
|
- `examples/purchase/` — `send_stars.py`, `send_premium.py`, `topup_ton_balance.py`, `run_stars_giveaway.py`, `run_premium_giveaway.py`, `recharge_ads_balance.py`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- All result types now expose a unified `amount` field (`months` and `stars` removed)
|
- All result types now expose a unified `amount` field (`months` and `stars` removed)
|
||||||
- `__repr__` includes the unit — `3 months`, `500 stars`, etc.
|
- `__repr__` includes the unit — `3 months`, `500 stars`, etc.
|
||||||
- `timestamp` removed from all result dataclasses
|
- `timestamp` removed from all result dataclasses
|
||||||
@@ -91,15 +239,18 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
## [2026.0.2] — 2026-03-20
|
## [2026.0.2] — 2026-03-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request
|
- `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Cookie validation: narrowed type internally so no `# type: ignore` is needed in `FragmentClient.__init__`
|
- Cookie validation: narrowed type internally so no `# type: ignore` is needed in `FragmentClient.__init__`
|
||||||
- `WALLET_CLASSES` typed as `dict[str, Any]` so mypy resolves `from_mnemonic` correctly
|
- `WALLET_CLASSES` typed as `dict[str, Any]` so mypy resolves `from_mnemonic` correctly
|
||||||
- All four `examples/` files updated to `async with FragmentClient`, f-strings, and aligned error messages
|
- All four `examples/` files updated to `async with FragmentClient`, f-strings, and aligned error messages
|
||||||
- README usage section rewritten with a single comprehensive `async with` example
|
- README usage section rewritten with a single comprehensive `async with` example
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- mypy: missing return path in `process_transaction` after retry loop
|
- mypy: missing return path in `process_transaction` after retry loop
|
||||||
- mypy: `cookies` union-attr error in `FragmentClient.__init__`
|
- mypy: `cookies` union-attr error in `FragmentClient.__init__`
|
||||||
|
|
||||||
@@ -108,6 +259,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
## [2026.0.1] — 2026-03-16
|
## [2026.0.1] — 2026-03-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Initial stable release of `pyfragment`
|
- Initial stable release of `pyfragment`
|
||||||
- `FragmentClient` — async client for the Fragment.com API with context manager support (`async with`)
|
- `FragmentClient` — async client for the Fragment.com API with context manager support (`async with`)
|
||||||
- `purchase_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months)
|
- `purchase_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months)
|
||||||
@@ -119,6 +271,11 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
- `py.typed` marker — full PEP 561 typing support for type-checkers
|
- `py.typed` marker — full PEP 561 typing support for type-checkers
|
||||||
- `__repr__` on all result types for readable debug output
|
- `__repr__` on all result types for readable debug output
|
||||||
|
|
||||||
|
[2026.3.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.2
|
||||||
|
[2026.3.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.1
|
||||||
|
[2026.3.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.0
|
||||||
|
[2026.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.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.1
|
||||||
[2026.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0
|
[2026.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0
|
||||||
[2026.1.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.1.0
|
[2026.1.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.1.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,26 +1,21 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="Fragment Logo" width="120" height="120" style="border-radius: 24px;">
|
<img src="icon.svg" alt="pyfragment" width="96" height="96" style="border-radius: 20px;"><br><br>
|
||||||
|
|
||||||
<h1 style="margin-top: 24px;">Fragment API</h1>
|
# pyfragment
|
||||||
|
|
||||||
<p style="font-size: 18px; margin-bottom: 24px;">
|
[](https://pypi.org/project/pyfragment/)
|
||||||
<b>Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts.</b>
|
[](https://pepy.tech/projects/pyfragment)
|
||||||
</p>
|
[](https://python.org)
|
||||||
|
[](https://github.com/bohd4nx/pyfragment/actions)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
[](https://pypi.org/project/pyfragment/)
|
Async Python client for the **[Fragment.com](https://fragment.com)** marketplace API.
|
||||||
[](https://pypi.org/project/pyfragment/)
|
|
||||||
[](https://python.org)
|
|
||||||
[](LICENSE)
|
|
||||||
[](https://github.com/bohd4nx/pyfragment/stargazers)
|
|
||||||
[](https://github.com/bohd4nx/pyfragment/actions)
|
|
||||||
|
|
||||||
[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>
|
</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
|
## Installation
|
||||||
|
|
||||||
@@ -28,116 +23,43 @@
|
|||||||
pip install pyfragment
|
pip install pyfragment
|
||||||
```
|
```
|
||||||
|
|
||||||
To install the latest unreleased changes from the `dev` branch:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Latest dev build
|
||||||
pip install git+https://github.com/bohd4nx/pyfragment.git@dev
|
pip install git+https://github.com/bohd4nx/pyfragment.git@dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Python 3.10+.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Quick Start
|
||||||
|
|
||||||
| Parameter | Type | Default | Description |
|
|
||||||
| ---------------- | ------------- | -------- | -------------------------------------------------------- |
|
|
||||||
| `seed` | `str` | — | 24-word TON wallet mnemonic |
|
|
||||||
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
|
|
||||||
| `cookies` | `dict \| str` | — | Fragment session cookies |
|
|
||||||
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` |
|
|
||||||
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Credentials
|
|
||||||
|
|
||||||
**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways:
|
|
||||||
|
|
||||||
- **Automatically** (recommended) — 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
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import asyncio
|
import asyncio
|
||||||
from pyfragment import (
|
|
||||||
FragmentClient,
|
from pyfragment import FragmentClient
|
||||||
FragmentError, # base — catches everything below
|
from pyfragment.enums import PaymentMethod
|
||||||
UserNotFoundError, # username doesn't exist on Fragment
|
|
||||||
WalletError, # insufficient balance or misconfiguration
|
|
||||||
CookieError, # cookies are missing or expired
|
|
||||||
TransactionError, # on-chain broadcast failed
|
|
||||||
ConfigurationError, # invalid argument (months, amount, etc.)
|
|
||||||
FragmentAPIError, # unexpected Fragment API response
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
async with FragmentClient(
|
async with FragmentClient(
|
||||||
seed="word1 word2 ... word24", # 24-word TON wallet mnemonic
|
seed="word1 word2 ... word24",
|
||||||
api_key="YOUR_TONAPI_KEY", # from tonconsole.com
|
api_key="YOUR_API_KEY",
|
||||||
cookies={
|
cookies={"stel_ssid": "...", "stel_dt": "...", "stel_token": "...", "stel_ton_token": "..."},
|
||||||
"stel_ssid": "...",
|
|
||||||
"stel_dt": "...",
|
|
||||||
"stel_token": "...",
|
|
||||||
"stel_ton_token": "...",
|
|
||||||
},
|
|
||||||
) as client:
|
) as client:
|
||||||
try:
|
wallet = await client.get_wallet()
|
||||||
# Purchase 6 months of Telegram Premium
|
print("GRAM: %s | USDT: %s" % (wallet.gram_balance, wallet.usdt_balance))
|
||||||
result = await client.purchase_premium("@username", months=6)
|
|
||||||
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
|
|
||||||
|
|
||||||
# Purchase 500 Stars
|
stars = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.USDT_GRAM)
|
||||||
result = await client.purchase_stars("@username", amount=500)
|
print("Sent %s Stars to %s | tx: %s" % (stars.amount, stars.username, stars.transaction_id))
|
||||||
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
|
|
||||||
|
|
||||||
# Top up 10 TON to Telegram balance
|
premium = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM)
|
||||||
# wallet must hold at least amount + ~0.056 TON for gas
|
print("Sent Premium %sm to %s | tx: %s" % (premium.amount, premium.username, premium.transaction_id))
|
||||||
result = await client.topup_ton("@username", amount=10)
|
|
||||||
print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}")
|
|
||||||
|
|
||||||
except UserNotFoundError:
|
|
||||||
print(f"User was not found on fragment.com — check the username and try again.")
|
|
||||||
except WalletError as e:
|
|
||||||
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
|
|
||||||
except CookieError:
|
|
||||||
print("Authentication failed — session cookies are missing or expired. Refresh them and retry.")
|
|
||||||
except TransactionError as e:
|
|
||||||
print(f"Transaction failed to broadcast on-chain: {e}")
|
|
||||||
except ConfigurationError as e:
|
|
||||||
print(f"Invalid argument: {e}")
|
|
||||||
except FragmentAPIError as e:
|
|
||||||
print(f"Unexpected response from Fragment API: {e}")
|
|
||||||
except FragmentError as e:
|
|
||||||
# catch-all for any other pyfragment error
|
|
||||||
print(f"Unexpected error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
### Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx)
|
[Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
|
||||||
|
|
||||||
**Star ⭐ this repo if you found it useful!**
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Please **do not** open a public GitHub issue for security vulnerabilities.
|
||||||
|
|
||||||
|
Report them privately via GitHub's [Security Advisory](https://github.com/bohd4nx/pyfragment/security/advisories/new) feature, or contact the maintainer directly at [@bohd4nx](https://t.me/bohd4nx) on Telegram.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- A description of the vulnerability and its potential impact.
|
||||||
|
- Steps to reproduce or a proof-of-concept.
|
||||||
|
- Affected versions.
|
||||||
|
|
||||||
|
You will receive a response within 72 hours. Once the fix is released, the advisory will be published.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This library handles sensitive credentials (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
|
import json
|
||||||
|
|
||||||
from pyfragment import FragmentClient, GiftsResult
|
from pyfragment import FragmentClient, GiftsResult
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -34,7 +33,13 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
|
|||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
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)
|
result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER)
|
||||||
|
|
||||||
print(f"Found {len(result.items)} result(s):")
|
print(f"Found {len(result.items)} result(s):")
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from pyfragment import FragmentClient, NumbersResult
|
from pyfragment import FragmentClient, NumbersResult
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -32,7 +31,13 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
|
|||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
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)
|
result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER)
|
||||||
|
|
||||||
print(f"Found {len(result.items)} result(s):")
|
print(f"Found {len(result.items)} result(s):")
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from pyfragment import FragmentClient, UsernamesResult
|
from pyfragment import FragmentClient, UsernamesResult
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -32,7 +31,13 @@ FILTER = "auction" # "", "auction", "sale", "sold" — or omit
|
|||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
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)
|
result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER)
|
||||||
|
|
||||||
print(f"Found {len(result.items)} result(s):")
|
print(f"Found {len(result.items)} result(s):")
|
||||||
|
|||||||
@@ -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
|
import asyncio
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import FragmentClient
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -34,7 +33,13 @@ PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragmen
|
|||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
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)
|
result = await client.call(METHOD, DATA, page_url=PAGE_URL)
|
||||||
print(result)
|
print(result)
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
Example: fetch wallet address, state, and balance.
|
Example: fetch wallet address, state, and separate GRAM (ex TON)/USDT balances.
|
||||||
|
|
||||||
Cookies can be passed as a dict or as a JSON string.
|
Cookies can be passed as a dict or as a JSON string.
|
||||||
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
|
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
|
||||||
|
api_provider defaults to "tonapi" (tonconsole.com) — pass "toncenter" to use t.me/toncenter instead.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import FragmentClient
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -30,12 +30,14 @@ async def main() -> None:
|
|||||||
seed=SEED,
|
seed=SEED,
|
||||||
api_key=API_KEY,
|
api_key=API_KEY,
|
||||||
cookies=COOKIES,
|
cookies=COOKIES,
|
||||||
wallet_version="V5R1", # or "V4R2"
|
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
|
||||||
|
api_provider="tonapi", # or "toncenter"
|
||||||
) as client:
|
) as client:
|
||||||
wallet = await client.get_wallet()
|
wallet = await client.get_wallet()
|
||||||
print(f"Address: {wallet.address}")
|
print(f"Address: {wallet.address}")
|
||||||
print(f"State: {wallet.state}")
|
print(f"State: {wallet.state}")
|
||||||
print(f"Balance: {wallet.balance} TON")
|
print(f"Balance: {wallet.gram_balance} GRAM (ex TON)")
|
||||||
|
print(f"Balance: {wallet.usdt_balance} USDT")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ Use terminate_sessions() to forcefully end all active Telegram sessions.
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from pyfragment import AnonymousNumberError, FragmentClient
|
from pyfragment import AnonymousNumberError, FragmentClient
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -29,7 +28,13 @@ NUMBER = "+88888888888"
|
|||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
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
|
# Fetch the latest login code
|
||||||
result = await client.get_login_code(NUMBER)
|
result = await client.get_login_code(NUMBER)
|
||||||
if result.code:
|
if result.code:
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ Example: run a Telegram Premium giveaway for a channel.
|
|||||||
|
|
||||||
winners must be an integer between 1 and 24 000.
|
winners must be an integer between 1 and 24 000.
|
||||||
months (Premium duration per winner) must be 3, 6, or 12.
|
months (Premium duration per winner) must be 3, 6, or 12.
|
||||||
|
Channel can be "@channel", "channel", or "https://t.me/channel".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
from pyfragment.enums import PaymentMethod
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -24,15 +25,27 @@ COOKIES = {
|
|||||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANNEL = "@channel"
|
CHANNEL = "https://t.me/channel"
|
||||||
WINNERS = 10 # 1–24 000
|
WINNERS = 10 # 1–24 000
|
||||||
MONTHS = 3 # 3, 6 or 12
|
MONTHS = 3 # 3, 6 or 12
|
||||||
|
PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
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:
|
try:
|
||||||
result = await client.giveaway_premium(CHANNEL, winners=WINNERS, months=MONTHS)
|
result = await client.giveaway_premium(
|
||||||
|
CHANNEL,
|
||||||
|
winners=WINNERS,
|
||||||
|
months=MONTHS,
|
||||||
|
payment_method=PAYMENT_METHOD,
|
||||||
|
)
|
||||||
except UserNotFoundError:
|
except UserNotFoundError:
|
||||||
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
||||||
return
|
return
|
||||||
@@ -3,15 +3,16 @@ Example: purchase Telegram Premium for a user.
|
|||||||
|
|
||||||
Supported durations: 3, 6, or 12 months.
|
Supported durations: 3, 6, or 12 months.
|
||||||
Set show_sender=False to send anonymously.
|
Set show_sender=False to send anonymously.
|
||||||
|
Username can be "@username", "username", or "https://t.me/username".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
from pyfragment.enums import PaymentMethod
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -24,14 +25,26 @@ COOKIES = {
|
|||||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
}
|
}
|
||||||
|
|
||||||
USERNAME = "@username"
|
USERNAME = "https://t.me/username"
|
||||||
MONTHS = 3 # 3, 6 or 12
|
MONTHS = 3 # 3, 6 or 12
|
||||||
|
PAYMENT_METHOD = PaymentMethod.GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
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:
|
try:
|
||||||
result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True)
|
result = await client.purchase_premium(
|
||||||
|
USERNAME,
|
||||||
|
months=MONTHS,
|
||||||
|
show_sender=True,
|
||||||
|
payment_method=PAYMENT_METHOD,
|
||||||
|
)
|
||||||
except UserNotFoundError:
|
except UserNotFoundError:
|
||||||
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||||
return
|
return
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Example: purchase Telegram Stars for a user.
|
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.
|
Set show_sender=False to send anonymously.
|
||||||
|
Username can be "@username", "username", or "https://t.me/username".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
from pyfragment.enums import PaymentMethod
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -24,14 +25,26 @@ COOKIES = {
|
|||||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
}
|
}
|
||||||
|
|
||||||
USERNAME = "@username"
|
USERNAME = "https://t.me/username"
|
||||||
AMOUNT = 500 # 50–1 000 000 stars
|
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 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:
|
try:
|
||||||
result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True)
|
result = await client.purchase_stars(
|
||||||
|
USERNAME,
|
||||||
|
amount=AMOUNT,
|
||||||
|
show_sender=True,
|
||||||
|
payment_method=PAYMENT_METHOD,
|
||||||
|
)
|
||||||
except UserNotFoundError:
|
except UserNotFoundError:
|
||||||
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||||
return
|
return
|
||||||
@@ -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.
|
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON).
|
||||||
Your wallet must hold at least the recharge amount + ~0.056 TON for gas.
|
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -13,10 +13,9 @@ from pyfragment import (
|
|||||||
FragmentClient,
|
FragmentClient,
|
||||||
WalletError,
|
WalletError,
|
||||||
)
|
)
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -30,11 +29,17 @@ COOKIES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
|
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
|
||||||
AMOUNT = 10 # 1–1 000 000 000 TON
|
AMOUNT = 10 # 1–1 000 000 000 GRAM (ex TON)
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
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:
|
try:
|
||||||
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
|
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
|
||||||
except WalletError as e:
|
except WalletError as e:
|
||||||
@@ -44,7 +49,7 @@ async def main() -> None:
|
|||||||
print(f"Invalid argument: {e}")
|
print(f"Invalid argument: {e}")
|
||||||
return
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Example: run a Telegram Stars giveaway for a channel.
|
Example: run a Telegram Stars giveaway for a channel.
|
||||||
|
|
||||||
winners must be an integer between 1 and 5.
|
winners must be an integer between 1 and 15.
|
||||||
amount (stars per winner) must be an integer between 500 and 1 000 000.
|
amount (stars per winner) must be an integer between 500 and 1 000 000.
|
||||||
|
Channel can be "@channel", "channel", or "https://t.me/channel".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
from pyfragment.enums import PaymentMethod
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -24,15 +25,27 @@ COOKIES = {
|
|||||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANNEL = "@channel"
|
CHANNEL = "https://t.me/channel"
|
||||||
WINNERS = 3 # 1–5
|
WINNERS = 3 # 1–15
|
||||||
AMOUNT = 1000 # 500–1 000 000 stars per winner
|
AMOUNT = 1000 # 500–1 000 000 stars per winner
|
||||||
|
PAYMENT_METHOD = PaymentMethod.USDT_GRAM # GRAM, USDT_GRAM, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
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:
|
try:
|
||||||
result = await client.giveaway_stars(CHANNEL, winners=WINNERS, amount=AMOUNT)
|
result = await client.giveaway_stars(
|
||||||
|
CHANNEL,
|
||||||
|
winners=WINNERS,
|
||||||
|
amount=AMOUNT,
|
||||||
|
payment_method=PAYMENT_METHOD,
|
||||||
|
)
|
||||||
except UserNotFoundError:
|
except UserNotFoundError:
|
||||||
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
|
||||||
return
|
return
|
||||||
@@ -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.
|
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON).
|
||||||
Your wallet must hold at least the top-up amount + ~0.056 TON for gas.
|
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -15,10 +15,9 @@ from pyfragment import (
|
|||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
WalletError,
|
WalletError,
|
||||||
)
|
)
|
||||||
from pyfragment.utils import get_cookies_from_browser # noqa: F401
|
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
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)
|
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
|
||||||
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
|
||||||
@@ -32,13 +31,19 @@ COOKIES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
USERNAME = "@username"
|
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 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:
|
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:
|
except UserNotFoundError:
|
||||||
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
|
||||||
return
|
return
|
||||||
@@ -49,7 +54,7 @@ async def main() -> None:
|
|||||||
print(f"Invalid argument: {e}")
|
print(f"Invalid argument: {e}")
|
||||||
return
|
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__":
|
if __name__ == "__main__":
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512" height="512" style="width: 100%; height: 100%; transform: translate3d(0,0,0); content-visibility: visible;" preserveAspectRatio="xMidYMid meet"><defs><clipPath id="__lottie_element_2"><rect width="512" height="512" x="0" y="0"></rect></clipPath><clipPath id="__lottie_element_4"><path d="M0,0 L100,0 L100,100 L0,100z"></path></clipPath></defs><g clip-path="url(#__lottie_element_2)"><g clip-path="url(#__lottie_element_4)" style="display: block;" transform="matrix(5.119999885559082,0,0,5.119999885559082,0,0)" opacity="1"><g style="display: block;" transform="matrix(1.333299994468689,0,0,1.333299994468689,0,0)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(30,40,51)" fill-opacity="1" d=" M47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 27.68000030517578,5.619999885559082 27.68000030517578,5.619999885559082 C15.5,5.619999885559082 5.619999885559082,15.5 5.619999885559082,27.68000030517578 C5.619999885559082,27.68000030517578 5.619999885559082,47.31999969482422 5.619999885559082,47.31999969482422 C5.619999885559082,59.5 15.5,69.37999725341797 27.68000030517578,69.37999725341797 C27.68000030517578,69.37999725341797 47.31999969482422,69.37999725341797 47.31999969482422,69.37999725341797 C59.5,69.37999725341797 69.37999725341797,59.5 69.37999725341797,47.31999969482422 C69.37999725341797,47.31999969482422 69.37999725341797,27.68000030517578 69.37999725341797,27.68000030517578 C69.37999725341797,15.5 59.5,5.619999885559082 47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082z"></path></g><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(255,255,255)" fill-opacity="1" d=" M36.349998474121094,32.79999923706055 C36.349998474121094,32.79999923706055 21.1299991607666,25.940000534057617 21.1299991607666,25.940000534057617 C20.020000457763672,25.450000762939453 20.389999389648438,23.790000915527344 21.600000381469727,23.790000915527344 C21.600000381469727,23.790000915527344 53.41999816894531,23.790000915527344 53.41999816894531,23.790000915527344 C54.630001068115234,23.790000915527344 54.9900016784668,25.440000534057617 53.880001068115234,25.940000534057617 C53.880001068115234,25.940000534057617 38.66999816894531,32.79999923706055 38.66999816894531,32.79999923706055 C37.939998626708984,33.130001068115234 37.09000015258789,33.130001068115234 36.36000061035156,32.79999923706055 C36.36000061035156,32.79999923706055 36.349998474121094,32.79999923706055 36.349998474121094,32.79999923706055z M56.81999969482422,30.06999969482422 C57.43000030517578,29.1200008392334 56.43000030517578,27.979999542236328 55.400001525878906,28.440000534057617 C55.400001525878906,28.440000534057617 40.72999954223633,35.13999938964844 40.72999954223633,35.13999938964844 C39.72999954223633,35.599998474121094 39.09000015258789,36.61000061035156 39.09000015258789,37.70000076293945 C39.09000015258789,37.70000076293945 39.09000015258789,53.810001373291016 39.09000015258789,53.810001373291016 C39.09000015258789,54.93000030517578 40.54999923706055,55.36000061035156 41.15999984741211,54.41999816894531 C41.15999984741211,54.41999816894531 56.810001373291016,30.06999969482422 56.810001373291016,30.06999969482422 C56.810001373291016,30.06999969482422 56.81999969482422,30.06999969482422 56.81999969482422,30.06999969482422z M19.600000381469727,28.440000534057617 C18.579999923706055,27.979999542236328 17.56999969482422,29.1200008392334 18.18000030517578,30.06999969482422 C18.18000030517578,30.06999969482422 33.84000015258789,54.43000030517578 33.84000015258789,54.43000030517578 C34.45000076293945,55.380001068115234 35.90999984741211,54.939998626708984 35.90999984741211,53.81999969482422 C35.90999984741211,53.81999969482422 35.90999984741211,37.70000076293945 35.90999984741211,37.70000076293945 C35.90999984741211,36.599998474121094 35.27000045776367,35.599998474121094 34.27000045776367,35.13999938964844 C34.27000045776367,35.13999938964844 19.59000015258789,28.450000762939453 19.59000015258789,28.450000762939453 C19.59000015258789,28.450000762939453 19.600000381469727,28.440000534057617 19.600000381469727,28.440000534057617z"></path></g></g></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
+36
-34
@@ -1,71 +1,73 @@
|
|||||||
# Copyright (c) 2026 bohd4nx
|
import logging
|
||||||
#
|
|
||||||
# This source code is licensed under the MIT License found in the
|
|
||||||
# LICENSE file in the root directory of this source tree.
|
|
||||||
|
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
from pyfragment.types import (
|
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult
|
||||||
AdsRechargeResult,
|
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
|
||||||
AdsTopupResult,
|
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,
|
AnonymousNumberError,
|
||||||
ClientError,
|
ClientError,
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
CookieError,
|
CookieError,
|
||||||
CookieResult,
|
|
||||||
FragmentAPIError,
|
FragmentAPIError,
|
||||||
FragmentError,
|
FragmentError,
|
||||||
FragmentPageError,
|
FragmentPageError,
|
||||||
GiftsResult,
|
|
||||||
LoginCodeResult,
|
|
||||||
NumbersResult,
|
|
||||||
OperationError,
|
OperationError,
|
||||||
ParseError,
|
ParseError,
|
||||||
PremiumGiveawayResult,
|
|
||||||
PremiumResult,
|
|
||||||
StarsGiveawayResult,
|
|
||||||
StarsResult,
|
|
||||||
TerminateSessionsResult,
|
|
||||||
TransactionError,
|
TransactionError,
|
||||||
UnexpectedError,
|
UnexpectedError,
|
||||||
UsernamesResult,
|
|
||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
VerificationError,
|
VerificationError,
|
||||||
WalletError,
|
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")
|
__version__: str = version("pyfragment")
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
"FragmentClient",
|
"FragmentClient",
|
||||||
"AdsRechargeResult",
|
# results
|
||||||
|
"StarsResult",
|
||||||
|
"StarsGiveawayResult",
|
||||||
|
"PremiumResult",
|
||||||
|
"PremiumGiveawayResult",
|
||||||
|
"WalletInfo",
|
||||||
"AdsTopupResult",
|
"AdsTopupResult",
|
||||||
|
"AdsRechargeResult",
|
||||||
|
"CookieResult",
|
||||||
"GiftsResult",
|
"GiftsResult",
|
||||||
"LoginCodeResult",
|
"LoginCodeResult",
|
||||||
"NumbersResult",
|
"NumbersResult",
|
||||||
"PremiumGiveawayResult",
|
|
||||||
"PremiumResult",
|
|
||||||
"StarsGiveawayResult",
|
|
||||||
"StarsResult",
|
|
||||||
"TerminateSessionsResult",
|
"TerminateSessionsResult",
|
||||||
"UsernamesResult",
|
"UsernamesResult",
|
||||||
"WalletInfo",
|
# exceptions
|
||||||
"ClientError",
|
|
||||||
"ConfigurationError",
|
|
||||||
"CookieError",
|
|
||||||
"CookieResult",
|
|
||||||
"FragmentAPIError",
|
|
||||||
"FragmentError",
|
"FragmentError",
|
||||||
|
"FragmentAPIError",
|
||||||
"FragmentPageError",
|
"FragmentPageError",
|
||||||
|
"ConfigurationError",
|
||||||
|
"AlreadySubscribedError",
|
||||||
|
"UserNotFoundError",
|
||||||
|
"WalletError",
|
||||||
|
"VerificationError",
|
||||||
|
"TransactionError",
|
||||||
"AnonymousNumberError",
|
"AnonymousNumberError",
|
||||||
|
"ClientError",
|
||||||
|
"CookieError",
|
||||||
"OperationError",
|
"OperationError",
|
||||||
"ParseError",
|
"ParseError",
|
||||||
"TransactionError",
|
|
||||||
"UnexpectedError",
|
"UnexpectedError",
|
||||||
"UserNotFoundError",
|
# literal types
|
||||||
"VerificationError",
|
"ApiProvider",
|
||||||
"WalletError",
|
"PaymentMethod",
|
||||||
|
"WalletVersion",
|
||||||
|
"get_cookies_from_browser",
|
||||||
]
|
]
|
||||||
|
|||||||
+120
-160
@@ -1,45 +1,29 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from typing import Any
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
import httpx
|
from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
|
||||||
|
from pyfragment.core.validation import (
|
||||||
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
|
normalize_provider,
|
||||||
from pyfragment.methods.giveaway_premium import giveaway_premium
|
normalize_wallet_version,
|
||||||
from pyfragment.methods.giveaway_stars import giveaway_stars
|
parse_cookies,
|
||||||
from pyfragment.methods.purchase_premium import purchase_premium
|
validate_cookie_keys,
|
||||||
from pyfragment.methods.purchase_stars import purchase_stars
|
validate_credentials,
|
||||||
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.types.constants import (
|
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult
|
||||||
DEFAULT_TIMEOUT,
|
from pyfragment.domains.ads.service import AdsService
|
||||||
FRAGMENT_BASE_URL,
|
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
|
||||||
REQUIRED_COOKIE_KEYS,
|
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
|
||||||
SUPPORTED_WALLET_VERSIONS,
|
from pyfragment.domains.base import raw_api_call
|
||||||
WalletVersion,
|
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult
|
||||||
)
|
from pyfragment.domains.giveaways.service import GiveawaysService
|
||||||
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
|
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult
|
||||||
from pyfragment.utils.wallet import get_wallet_info
|
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:
|
class FragmentClient:
|
||||||
@@ -51,14 +35,18 @@ class FragmentClient:
|
|||||||
connected with Fragment or Telegram.
|
connected with Fragment or Telegram.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
seed: 24-word mnemonic phrase for the TON wallet.
|
seed: 12- or 24-word mnemonic phrase for the GRAM (ex TON) wallet.
|
||||||
api_key: Tonapi API key — get one at https://tonconsole.com.
|
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.
|
cookies: Fragment session cookies as a dict or JSON string.
|
||||||
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
|
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
|
||||||
|
api_provider: Blockchain API provider — ``"tonapi"`` (tonconsole.com, default)
|
||||||
|
or ``"toncenter"`` (t.me/toncenter).
|
||||||
timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
|
timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
|
||||||
|
headers: Custom HTTP request headers. If omitted, :data:`BASE_HEADERS` is used.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ConfigurationError: If ``seed``, ``api_key``, 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.
|
CookieError: If ``cookies`` cannot be parsed or are missing required keys.
|
||||||
|
|
||||||
Example::
|
Example::
|
||||||
@@ -79,42 +67,29 @@ class FragmentClient:
|
|||||||
api_key: str,
|
api_key: str,
|
||||||
cookies: dict[str, Any] | str,
|
cookies: dict[str, Any] | str,
|
||||||
wallet_version: str = "V5R1",
|
wallet_version: str = "V5R1",
|
||||||
|
api_provider: str = "tonapi",
|
||||||
timeout: float = DEFAULT_TIMEOUT,
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
|
validate_credentials(seed, api_key)
|
||||||
if missing:
|
provider = normalize_provider(api_provider)
|
||||||
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
|
parsed_cookies = parse_cookies(cookies)
|
||||||
|
validate_cookie_keys(parsed_cookies)
|
||||||
word_count = len(seed.split())
|
version = normalize_wallet_version(wallet_version)
|
||||||
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))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.seed: str = seed.strip()
|
self.seed: str = seed.strip()
|
||||||
self.api_key: str = api_key.strip()
|
self.api_key: str = api_key.strip()
|
||||||
self.cookies: dict[str, Any] = cast(dict[str, Any], cookies)
|
self.api_provider: ApiProvider = provider
|
||||||
self.wallet_version: WalletVersion = version # type: ignore[assignment]
|
self.cookies: dict[str, Any] = parsed_cookies
|
||||||
|
self.wallet_version: WalletVersion = version
|
||||||
self.timeout: float = timeout
|
self.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:
|
async def __aenter__(self) -> FragmentClient:
|
||||||
return self
|
return self
|
||||||
@@ -123,118 +98,132 @@ class FragmentClient:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
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) -> PremiumResult:
|
async def purchase_premium(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
months: int,
|
||||||
|
show_sender: bool = True,
|
||||||
|
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||||
|
) -> PremiumResult:
|
||||||
"""Gift Telegram Premium to a user.
|
"""Gift Telegram Premium to a user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: Recipient's Telegram username (with or without ``@``).
|
username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
|
||||||
months: Duration — ``3``, ``6``, or ``12``.
|
months: Duration — ``3``, ``6``, or ``12``.
|
||||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||||
|
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
"""
|
"""
|
||||||
return await purchase_premium(self, username, months, show_sender)
|
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) -> StarsResult:
|
async def purchase_stars(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
amount: int,
|
||||||
|
show_sender: bool = True,
|
||||||
|
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||||
|
) -> StarsResult:
|
||||||
"""Send Telegram Stars to a user.
|
"""Send Telegram Stars to a user.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: Recipient's Telegram username (with or without ``@``).
|
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``.
|
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||||
|
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
"""
|
"""
|
||||||
return await purchase_stars(self, username, amount, show_sender)
|
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:
|
async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||||
"""Top up TON to a recipient's Telegram balance.
|
"""Top up GRAM (ex TON) to a recipient's Telegram balance.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: Recipient's Telegram username (with or without ``@``).
|
username: Recipient's Telegram username (with or without ``@``).
|
||||||
amount: Amount in 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``.
|
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
|
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
|
||||||
"""
|
"""
|
||||||
return await 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:
|
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
|
||||||
"""Add funds to your own Telegram Ads account.
|
"""Add funds to your own Telegram Ads account.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
account: Your Fragment Ads account identifier — the channel or bot username
|
account: Channel or bot username the Ads account is linked to (e.g. ``"@mychannel"``).
|
||||||
the Ads account is linked to (e.g. ``"@mychannel"``).
|
amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``.
|
||||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
|
: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:
|
async def get_wallet(self) -> WalletInfo:
|
||||||
"""Return the address, state and balance of the TON wallet.
|
"""Return the address, state, and balances of the wallet.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
|
:class:`WalletInfo` with ``address``, ``state``, ``gram_balance``, and ``usdt_balance``.
|
||||||
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON.
|
|
||||||
"""
|
"""
|
||||||
return await get_wallet_info(self)
|
return await self.tonapi.get_wallet()
|
||||||
|
|
||||||
async def giveaway_stars(
|
async def giveaway_stars(
|
||||||
self,
|
self,
|
||||||
channel: str,
|
channel: str,
|
||||||
winners: int,
|
winners: int,
|
||||||
amount: int,
|
amount: int,
|
||||||
|
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||||
) -> StarsGiveawayResult:
|
) -> StarsGiveawayResult:
|
||||||
"""Run a Telegram Stars giveaway for a channel.
|
"""Run a Telegram Stars giveaway for a channel.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
channel: Channel username (with or without ``@``).
|
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``.
|
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
||||||
|
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
|
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``.
|
||||||
``winners``, and ``amount``.
|
|
||||||
"""
|
"""
|
||||||
return await giveaway_stars(self, channel, winners, amount)
|
return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method)
|
||||||
|
|
||||||
async def giveaway_premium(
|
async def giveaway_premium(
|
||||||
self,
|
self,
|
||||||
channel: str,
|
channel: str,
|
||||||
winners: int,
|
winners: int,
|
||||||
months: int = 3,
|
months: int = 3,
|
||||||
|
payment_method: PaymentMethod = PaymentMethod.GRAM,
|
||||||
) -> PremiumGiveawayResult:
|
) -> PremiumGiveawayResult:
|
||||||
"""Run a Telegram Premium giveaway for a channel.
|
"""Run a Telegram Premium giveaway for a channel.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
channel: Channel username (with or without ``@``).
|
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``.
|
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
||||||
|
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
|
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``.
|
||||||
``winners``, and ``amount``.
|
|
||||||
"""
|
"""
|
||||||
return await giveaway_premium(self, channel, winners, months)
|
return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method)
|
||||||
|
|
||||||
async def get_login_code(self, number: str) -> LoginCodeResult:
|
async def get_login_code(self, number: str) -> LoginCodeResult:
|
||||||
"""Fetch the current pending login code for an anonymous number.
|
"""Fetch the current pending login code for an anonymous number.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
|
number: Phone number with or without leading ``+``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
|
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
|
||||||
and ``active_sessions`` count.
|
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:
|
async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
|
||||||
"""Enable or disable login code delivery for an anonymous number.
|
"""Enable or disable login code delivery for an anonymous number.
|
||||||
@@ -243,7 +232,7 @@ class FragmentClient:
|
|||||||
number: Phone number with or without leading ``+``.
|
number: Phone number with or without leading ``+``.
|
||||||
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
|
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:
|
async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
|
||||||
"""Terminate all active Telegram sessions for an anonymous number.
|
"""Terminate all active Telegram sessions for an anonymous number.
|
||||||
@@ -255,9 +244,9 @@ class FragmentClient:
|
|||||||
:class:`TerminateSessionsResult` with ``number`` and ``message``.
|
:class:`TerminateSessionsResult` with ``number`` and ``message``.
|
||||||
|
|
||||||
Raises:
|
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(
|
async def search_usernames(
|
||||||
self,
|
self,
|
||||||
@@ -269,19 +258,15 @@ class FragmentClient:
|
|||||||
"""Search the Fragment marketplace for Telegram usernames.
|
"""Search the Fragment marketplace for Telegram usernames.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
|
query: Search text. Omit or pass ``""`` to browse all.
|
||||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
|
||||||
``"ending"``. Omit to use Fragment's default ordering.
|
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
|
||||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
offset_id: Pass :attr:`UsernamesResult.next_offset_id` to fetch the next page.
|
||||||
``""`` (available items). Omit to return all.
|
|
||||||
offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id`
|
|
||||||
from a previous result to fetch the next page.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`UsernamesResult` with ``items`` (parsed list of item dicts)
|
:class:`UsernamesResult` with ``items`` and ``next_offset_id``.
|
||||||
and ``next_offset_id`` (``None`` on the last page).
|
|
||||||
"""
|
"""
|
||||||
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(
|
async def search_numbers(
|
||||||
self,
|
self,
|
||||||
@@ -293,19 +278,15 @@ class FragmentClient:
|
|||||||
"""Search the Fragment marketplace for anonymous Telegram numbers.
|
"""Search the Fragment marketplace for anonymous Telegram numbers.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
|
query: Search text. Omit or pass ``""`` to browse all.
|
||||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
|
||||||
``"ending"``. Omit to use Fragment's default ordering.
|
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
|
||||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
offset_id: Pass :attr:`NumbersResult.next_offset_id` to fetch the next page.
|
||||||
``""`` (available items). Omit to return all.
|
|
||||||
offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id`
|
|
||||||
from a previous result to fetch the next page.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`NumbersResult` with ``items`` (parsed list of item dicts)
|
:class:`NumbersResult` with ``items`` and ``next_offset_id``.
|
||||||
and ``next_offset_id`` (``None`` on the last page).
|
|
||||||
"""
|
"""
|
||||||
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(
|
async def search_gifts(
|
||||||
self,
|
self,
|
||||||
@@ -320,25 +301,19 @@ class FragmentClient:
|
|||||||
"""Search the Fragment gifts marketplace.
|
"""Search the Fragment gifts marketplace.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Search text. Omit or pass ``""`` to browse without filtering by name.
|
query: Search text. Omit or pass ``""`` to browse all.
|
||||||
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
|
collection: Gift collection slug (e.g. ``"artisanbrick"``).
|
||||||
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
|
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``.
|
||||||
``"ending"``. Omit to use Fragment's default ordering.
|
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available).
|
||||||
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
|
view: Active attribute tab name (e.g. ``"Model"``).
|
||||||
``""`` (available items). Omit to return all.
|
attr: Attribute filters — e.g. ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}``.
|
||||||
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
|
offset: Pass :attr:`GiftsResult.next_offset` to fetch the next page.
|
||||||
attr: Attribute filters — mapping of trait name to accepted values, e.g.
|
|
||||||
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
|
|
||||||
Each key is sent as ``attr[Key]`` with its list of values.
|
|
||||||
offset: Integer page offset from a previous :class:`GiftsResult`.
|
|
||||||
Pass ``next_offset`` to fetch the next page.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`GiftsResult` with ``items`` (parsed list of item dicts)
|
:class:`GiftsResult` with ``items`` and ``next_offset``.
|
||||||
and ``next_offset`` (``None`` on the last page).
|
|
||||||
"""
|
"""
|
||||||
return await search_gifts(
|
return await self.marketplace.search_gifts(
|
||||||
self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
|
query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
|
||||||
)
|
)
|
||||||
|
|
||||||
async def call(
|
async def call(
|
||||||
@@ -346,27 +321,12 @@ class FragmentClient:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Send a raw request to the Fragment API.
|
"""Send a raw request to the Fragment API.
|
||||||
|
|
||||||
Useful for accessing undocumented or future Fragment API methods
|
|
||||||
without waiting for a library update.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``.
|
method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``.
|
||||||
data: Additional form-data fields to include in the request body.
|
data: Additional form-data fields.
|
||||||
page_url: Fragment page URL used to derive the API hash and headers.
|
page_url: Fragment page URL to derive the API hash. Defaults to ``FRAGMENT_BASE_URL``.
|
||||||
Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Raw parsed JSON response as a dict.
|
Raw parsed JSON response as a dict.
|
||||||
|
|
||||||
Example::
|
|
||||||
|
|
||||||
result = await client.call(
|
|
||||||
"searchPremiumGiftRecipient",
|
|
||||||
{"query": "@username", "months": 3},
|
|
||||||
page_url="https://fragment.com/premium/gift",
|
|
||||||
)
|
|
||||||
"""
|
"""
|
||||||
headers = make_headers(page_url)
|
return await raw_api_call(self.cookies, self.timeout, method, data, page_url, self.headers)
|
||||||
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 {})})
|
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pyfragment.types import (
|
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
|
||||||
AdsRechargeResult,
|
from pyfragment.domains.ads.models import AdsRechargeResult
|
||||||
ConfigurationError,
|
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
|
||||||
FragmentAPIError,
|
from pyfragment.services.tonapi.account import get_account_info
|
||||||
FragmentError,
|
from pyfragment.services.tonapi.transaction import process_transaction
|
||||||
UnexpectedError,
|
|
||||||
VerificationError,
|
|
||||||
)
|
|
||||||
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
|
||||||
from pyfragment.utils import get_account_info, process_transaction
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
|
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
|
||||||
"""Add funds to your own Telegram Ads account.
|
if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT)
|
||||||
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)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
|
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",
|
"getAdsRechargeLink",
|
||||||
{
|
{
|
||||||
"account": json.dumps(account_info),
|
"account": json.dumps(account_info),
|
||||||
"device": DEVICE,
|
"device": json.dumps(DEVICE_INFO),
|
||||||
"transaction": 1,
|
"transaction": 1,
|
||||||
"id": req_id,
|
"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)
|
tx_hash = await process_transaction(client, transaction)
|
||||||
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
|
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
|
||||||
|
|
||||||
except FragmentError:
|
except FragmentError as exc:
|
||||||
|
logger.error("Failed to recharge Ads account '%s' for %s GRAM (ex TON): %s", account, amount, exc, exc_info=True)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to recharge Ads account '%s' for %s GRAM (ex TON) due to an unexpected error", account, amount)
|
||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pyfragment.types import (
|
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
|
||||||
AdsTopupResult,
|
from pyfragment.domains.ads.models import AdsTopupResult
|
||||||
|
from pyfragment.domains.payments import parse_required_payment_amount
|
||||||
|
from pyfragment.exceptions import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
FragmentAPIError,
|
FragmentAPIError,
|
||||||
FragmentError,
|
FragmentError,
|
||||||
@@ -12,33 +15,19 @@ from pyfragment.types import (
|
|||||||
UserNotFoundError,
|
UserNotFoundError,
|
||||||
VerificationError,
|
VerificationError,
|
||||||
)
|
)
|
||||||
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
from pyfragment.services.tonapi.account import get_account_info
|
||||||
from pyfragment.utils import get_account_info, process_transaction
|
from pyfragment.services.tonapi.transaction import process_transaction
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
logger = logging.getLogger(__name__)
|
||||||
"""Top up TON to a recipient's Telegram balance.
|
|
||||||
|
|
||||||
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:
|
async def topup_gram(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||||
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
|
if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
|
||||||
|
raise ConfigurationError(ConfigurationError.INVALID_GRAM_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)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
|
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))
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||||
|
|
||||||
result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE)
|
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")
|
req_id = result.get("req_id")
|
||||||
if not 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)
|
account = await get_account_info(client)
|
||||||
transaction = await client.call(
|
transaction = await client.call(
|
||||||
"getAdsTopupLink",
|
"getAdsTopupLink",
|
||||||
{
|
{
|
||||||
"account": json.dumps(account),
|
"account": json.dumps(account),
|
||||||
"device": DEVICE,
|
"device": json.dumps(DEVICE_INFO),
|
||||||
"transaction": 1,
|
"transaction": 1,
|
||||||
"id": req_id,
|
"id": req_id,
|
||||||
"show_sender": int(show_sender),
|
"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"):
|
if transaction.get("need_verify"):
|
||||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
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)
|
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
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to top up GRAM (ex TON) for user '%s' with %s GRAM (ex TON) due to an unexpected error", username, amount
|
||||||
|
)
|
||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pyfragment.types import (
|
from pyfragment.core.constants import NUMBERS_PAGE
|
||||||
AnonymousNumberError,
|
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult
|
||||||
FragmentAPIError,
|
from pyfragment.domains.anonymous_numbers.parser import parse_login_code
|
||||||
FragmentError,
|
from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError
|
||||||
LoginCodeResult,
|
|
||||||
TerminateSessionsResult,
|
|
||||||
UnexpectedError,
|
|
||||||
)
|
|
||||||
from pyfragment.types.constants import NUMBERS_PAGE
|
|
||||||
from pyfragment.utils import parse_login_code
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _strip_plus(number: str) -> str:
|
def _strip_plus(number: str) -> str:
|
||||||
return number.lstrip("+") if isinstance(number, str) else number
|
return number.lstrip("+") if isinstance(number, str) else number
|
||||||
|
|
||||||
|
|
||||||
async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult:
|
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:
|
try:
|
||||||
clean = _strip_plus(number)
|
clean = _strip_plus(number)
|
||||||
result = await client.call(
|
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)
|
return LoginCodeResult(number=number, code=code, active_sessions=active_sessions)
|
||||||
|
|
||||||
except FragmentError:
|
except FragmentError as exc:
|
||||||
|
logger.error("Failed to get login code for number '%s': %s", number, exc, exc_info=True)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to get login code for number '%s' due to an unexpected error", number)
|
||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None:
|
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:
|
try:
|
||||||
clean = _strip_plus(number)
|
clean = _strip_plus(number)
|
||||||
result = await client.call(
|
result = await client.call(
|
||||||
@@ -81,31 +56,25 @@ async def toggle_login_codes(client: FragmentClient, number: str, can_receive: b
|
|||||||
if result.get("error"):
|
if result.get("error"):
|
||||||
raise FragmentAPIError(html.unescape(result["error"]))
|
raise FragmentAPIError(html.unescape(result["error"]))
|
||||||
|
|
||||||
except FragmentError:
|
except FragmentError as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to toggle login code delivery for number '%s' (can_receive=%s): %s",
|
||||||
|
number,
|
||||||
|
can_receive,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to toggle login code delivery for number '%s' (can_receive=%s) due to an unexpected error",
|
||||||
|
number,
|
||||||
|
can_receive,
|
||||||
|
)
|
||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult:
|
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:
|
try:
|
||||||
clean = _strip_plus(number)
|
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"))
|
return TerminateSessionsResult(number=number, message=result.get("msg"))
|
||||||
|
|
||||||
except FragmentError:
|
except FragmentError as exc:
|
||||||
|
logger.error("Failed to terminate sessions for number '%s': %s", number, exc, exc_info=True)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to terminate sessions for number '%s' due to an unexpected error", number)
|
||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||||
@@ -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})"
|
||||||
@@ -3,22 +3,14 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
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)
|
ROW_BLOCK_RE = re.compile(r'<tr\b[^>]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)</tr>', re.DOTALL)
|
||||||
HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"')
|
HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"')
|
||||||
VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<')
|
VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<')
|
||||||
PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\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_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>')
|
||||||
DATETIME_SHORT_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="short-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,. ]+$")
|
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_ITEM_RE = re.compile(r'<a\b[^>]*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)</a>', re.DOTALL)
|
||||||
GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))')
|
GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))')
|
||||||
GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<')
|
GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<')
|
||||||
@@ -28,39 +20,7 @@ GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<
|
|||||||
GRID_DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"')
|
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]]:
|
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]] = []
|
items: list[dict[str, Any]] = []
|
||||||
for row_match in ROW_BLOCK_RE.finditer(html):
|
for row_match in ROW_BLOCK_RE.finditer(html):
|
||||||
row = row_match.group(1)
|
row = row_match.group(1)
|
||||||
@@ -68,21 +28,17 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]:
|
|||||||
href_m = HREF_RE.search(row)
|
href_m = HREF_RE.search(row)
|
||||||
if not href_m:
|
if not href_m:
|
||||||
continue
|
continue
|
||||||
slug = href_m.group(1).lstrip("/") # e.g. "username/durov"
|
slug = href_m.group(1).lstrip("/")
|
||||||
|
|
||||||
# All tm-value spans in the row — first is the display name
|
|
||||||
values = [m.group(1).strip() for m in VALUE_RE.finditer(row)]
|
values = [m.group(1).strip() for m in VALUE_RE.finditer(row)]
|
||||||
name = values[0] if values else slug
|
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
|
status: str | None = None
|
||||||
for v in values[1:]:
|
for v in values[1:]:
|
||||||
if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v):
|
if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v):
|
||||||
status = v
|
status = v
|
||||||
break
|
break
|
||||||
|
|
||||||
# Price — look for icon-ton pattern, format as two decimal places
|
|
||||||
price_m = PRICE_RE.search(row)
|
price_m = PRICE_RE.search(row)
|
||||||
price: str | None = None
|
price: str | None = None
|
||||||
if price_m:
|
if price_m:
|
||||||
@@ -92,38 +48,15 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
price = raw_price
|
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)
|
time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row)
|
||||||
date: str | None = time_m.group(1) if time_m else None
|
date: str | None = time_m.group(1) if time_m else None
|
||||||
|
|
||||||
items.append(
|
items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date})
|
||||||
{
|
|
||||||
"slug": slug,
|
|
||||||
"name": name,
|
|
||||||
"status": status,
|
|
||||||
"price": price,
|
|
||||||
"date": date,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
|
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]] = []
|
items: list[dict[str, Any]] = []
|
||||||
for item_match in GRID_ITEM_RE.finditer(html):
|
for item_match in GRID_ITEM_RE.finditer(html):
|
||||||
block = item_match.group(0)
|
block = item_match.group(0)
|
||||||
@@ -131,7 +64,7 @@ def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
|
|||||||
href_m = GRID_HREF_RE.search(block)
|
href_m = GRID_HREF_RE.search(block)
|
||||||
if not href_m:
|
if not href_m:
|
||||||
continue
|
continue
|
||||||
slug = href_m.group(1).lstrip("/") # e.g. "gift/plushpepe-1821"
|
slug = href_m.group(1).lstrip("/")
|
||||||
|
|
||||||
name_m = GRID_NAME_RE.search(block)
|
name_m = GRID_NAME_RE.search(block)
|
||||||
num_m = GRID_NUM_RE.search(block)
|
num_m = GRID_NUM_RE.search(block)
|
||||||
@@ -156,7 +89,6 @@ def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
|
|||||||
|
|
||||||
items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date})
|
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_m = re.search(r'data-next-offset="(\d+)"', html)
|
||||||
next_offset = int(next_offset_m.group(1)) if next_offset_m else None
|
next_offset = int(next_offset_m.group(1)) if next_offset_m else None
|
||||||
|
|
||||||
@@ -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,50 +1,68 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pyfragment.core.constants import (
|
||||||
|
GRAM_TOPUP_MAX,
|
||||||
|
GRAM_TOPUP_MIN,
|
||||||
|
MNEMONIC_WORD_COUNTS_VALID,
|
||||||
|
PREMIUM_MONTHS_VALID,
|
||||||
|
PREMIUM_WINNERS_MAX,
|
||||||
|
PREMIUM_WINNERS_MIN,
|
||||||
|
STARS_GIVEAWAY_MAX,
|
||||||
|
STARS_GIVEAWAY_MIN,
|
||||||
|
STARS_PURCHASE_MAX,
|
||||||
|
STARS_PURCHASE_MIN,
|
||||||
|
STARS_WINNERS_MAX,
|
||||||
|
STARS_WINNERS_MIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FragmentError(Exception):
|
class FragmentError(Exception):
|
||||||
"""Base exception for all pyfragment library errors."""
|
"""Base exception for all pyfragment errors."""
|
||||||
|
|
||||||
|
|
||||||
class ClientError(FragmentError):
|
class ClientError(FragmentError):
|
||||||
"""Raised for client configuration and setup issues (bad params, invalid cookies)."""
|
"""Raised for client configuration and setup issues."""
|
||||||
|
|
||||||
|
|
||||||
class ConfigurationError(ClientError):
|
class ConfigurationError(ClientError):
|
||||||
"""Raised when required client parameters are missing or invalid."""
|
"""Raised when required client parameters are missing or invalid."""
|
||||||
|
|
||||||
MISSING_VARS = "Missing required parameter(s): {keys}."
|
MISSING_VARS = "Missing required parameter(s): {keys}."
|
||||||
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
|
UNSUPPORTED_VERSION = "Unsupported wallet version '{version}'. Supported values: {supported}."
|
||||||
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
|
INVALID_MNEMONIC = f"Invalid mnemonic phrase: expected {', '.join(str(n) for n in sorted(MNEMONIC_WORD_COUNTS_VALID))} words, got {{count}}."
|
||||||
INVALID_API_KEY = (
|
UNSUPPORTED_PROVIDER = "Unsupported API provider '{provider}'. Supported values: {supported}."
|
||||||
"Invalid Tonapi API key: expected at least 68 characters, got {length}. Generate a key at https://tonconsole.com."
|
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_GRAM_AMOUNT = f"Invalid GRAM (ex TON) amount: must be an integer between {GRAM_TOPUP_MIN:,} and {GRAM_TOPUP_MAX:,}."
|
||||||
INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000."
|
INVALID_WINNERS_STARS = (
|
||||||
INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1 000 000 000."
|
f"Invalid winners count: must be an integer between {STARS_WINNERS_MIN:,} and {STARS_WINNERS_MAX:,}."
|
||||||
INVALID_USERNAME = (
|
|
||||||
"Invalid username '{username}'. "
|
|
||||||
"Must be 5–32 characters and contain only letters (A–Z, a–z), digits (0–9), or underscores (_)."
|
|
||||||
)
|
)
|
||||||
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
|
INVALID_WINNERS_PREMIUM = (
|
||||||
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000."
|
f"Invalid winners count: must be an integer between {PREMIUM_WINNERS_MIN:,} and {PREMIUM_WINNERS_MAX:,}."
|
||||||
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000."
|
)
|
||||||
|
INVALID_STARS_PER_WINNER = (
|
||||||
|
f"Invalid Stars per winner: must be an integer between {STARS_GIVEAWAY_MIN:,} and {STARS_GIVEAWAY_MAX:,}."
|
||||||
|
)
|
||||||
|
INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
|
||||||
|
|
||||||
|
|
||||||
class CookieError(ClientError):
|
class CookieError(ClientError):
|
||||||
"""Raised when cookies are unreadable or missing required fields."""
|
"""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 = (
|
MISSING_KEYS = (
|
||||||
"Fragment cookies are missing or empty for key(s): {keys}. "
|
"Fragment cookies are missing or empty for key(s): {keys}. "
|
||||||
"Open fragment.com in your browser, log in, and copy fresh cookies."
|
"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 = (
|
BROWSER_READ_FAILED = (
|
||||||
"Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}."
|
"Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}."
|
||||||
)
|
)
|
||||||
MISSING_BROWSER_KEYS = (
|
MISSING_BROWSER_KEYS = (
|
||||||
"Fragment cookies not found in {browser}: {keys}. "
|
"Fragment cookies not found in {browser}: {keys}. "
|
||||||
"Make sure you are logged in to {url} and have connected your 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."
|
EXPIRED = "Fragment session cookie expired at {expires}. Log in to fragment.com in your browser and extract fresh cookies."
|
||||||
|
|
||||||
@@ -52,23 +70,14 @@ class CookieError(ClientError):
|
|||||||
class FragmentAPIError(FragmentError):
|
class FragmentAPIError(FragmentError):
|
||||||
"""Raised for errors returned by Fragment's API responses."""
|
"""Raised for errors returned by Fragment's API responses."""
|
||||||
|
|
||||||
NO_REQUEST_ID = (
|
NO_REQUEST_ID = "Fragment did not return a request ID for '{context}'. Your session may have expired. Refresh your cookies and try again."
|
||||||
"Fragment did not return a request ID for '{context}'. "
|
|
||||||
"Your session may have expired — log in to fragment.com and refresh your cookies."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FragmentPageError(FragmentAPIError):
|
class FragmentPageError(FragmentAPIError):
|
||||||
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
|
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
|
||||||
|
|
||||||
BAD_STATUS = (
|
BAD_STATUS = "Fragment returned HTTP {status} when loading {url}. Your cookies may be invalid or expired. Refresh them and try again."
|
||||||
"Fragment returned HTTP {status} when loading {url}. "
|
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."
|
||||||
"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."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UserNotFoundError(FragmentAPIError):
|
class UserNotFoundError(FragmentAPIError):
|
||||||
@@ -77,6 +86,13 @@ class UserNotFoundError(FragmentAPIError):
|
|||||||
NOT_FOUND = (
|
NOT_FOUND = (
|
||||||
"Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
|
"Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
|
||||||
)
|
)
|
||||||
|
NOT_A_USER = "'{username}' does not belong to a user account. Make sure the username is assigned to a personal Telegram account, not a channel or bot."
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadySubscribedError(FragmentAPIError):
|
||||||
|
"""Raised when trying to gift Premium to a user who already has an active subscription."""
|
||||||
|
|
||||||
|
PREMIUM_ACTIVE = "This account is already subscribed to Telegram Premium."
|
||||||
|
|
||||||
|
|
||||||
class AnonymousNumberError(FragmentAPIError):
|
class AnonymousNumberError(FragmentAPIError):
|
||||||
@@ -87,11 +103,9 @@ class AnonymousNumberError(FragmentAPIError):
|
|||||||
|
|
||||||
|
|
||||||
class TransactionError(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 = (
|
INVALID_PAYLOAD = "Fragment returned an invalid transaction payload: 'transaction.messages' is missing or empty."
|
||||||
"Fragment returned an invalid transaction payload — 'transaction.messages' is missing or empty in the API response."
|
|
||||||
)
|
|
||||||
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
||||||
BROADCAST_FAILED_SSL = (
|
BROADCAST_FAILED_SSL = (
|
||||||
"Transaction broadcast failed due to an SSL certificate error: {exc}\n"
|
"Transaction broadcast failed due to an SSL certificate error: {exc}\n"
|
||||||
@@ -101,7 +115,7 @@ class TransactionError(FragmentAPIError):
|
|||||||
"located in your Python installation folder."
|
"located in your Python installation folder."
|
||||||
)
|
)
|
||||||
DUPLICATE_SEQNO = (
|
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) "
|
"because a previous transaction with the same sequence number (seqno) "
|
||||||
"is still pending confirmation on-chain.\n"
|
"is still pending confirmation on-chain.\n"
|
||||||
"Wait a few seconds for the previous transaction to confirm, then retry."
|
"Wait a few seconds for the previous transaction to confirm, then retry."
|
||||||
@@ -128,15 +142,16 @@ class OperationError(FragmentError):
|
|||||||
|
|
||||||
|
|
||||||
class WalletError(OperationError):
|
class WalletError(OperationError):
|
||||||
"""Raised for TON wallet issues (connection, balance, account info)."""
|
"""Raised for GRAM (ex TON) wallet issues (connection, balance, account info)."""
|
||||||
|
|
||||||
LOW_BALANCE = (
|
LOW_GRAM_BALANCE = (
|
||||||
"Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required "
|
"Insufficient GRAM (ex TON) balance: {balance:.4f} GRAM (ex TON) available, {required:.4f} GRAM (ex TON) required."
|
||||||
"(transaction amount + {gas:.3f} TON gas reserve)."
|
|
||||||
)
|
)
|
||||||
BALANCE_CHECK_FAILED = "Failed to fetch wallet balance: {exc}"
|
LOW_USDT_BALANCE = "Insufficient USDT balance: {balance:.4f} USDT available, {required:.4f} USDT required."
|
||||||
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}"
|
GRAM_BALANCE_CHECK_FAILED = "Failed to fetch GRAM (ex TON) balance: {exc}"
|
||||||
WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
|
USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}"
|
||||||
|
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from GRAM (ex TON) network: {exc}"
|
||||||
|
WALLET_INFO_FAILED = "Failed to retrieve wallet info from GRAM (ex TON) network: {exc}"
|
||||||
|
|
||||||
|
|
||||||
class UnexpectedError(OperationError):
|
class UnexpectedError(OperationError):
|
||||||
@@ -153,6 +168,7 @@ __all__ = [
|
|||||||
"FragmentAPIError",
|
"FragmentAPIError",
|
||||||
"FragmentPageError",
|
"FragmentPageError",
|
||||||
"AnonymousNumberError",
|
"AnonymousNumberError",
|
||||||
|
"AlreadySubscribedError",
|
||||||
"UserNotFoundError",
|
"UserNotFoundError",
|
||||||
"TransactionError",
|
"TransactionError",
|
||||||
"ParseError",
|
"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,95 +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
|
|
||||||
from pyfragment.utils import get_account_info, process_transaction
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pyfragment.client import FragmentClient
|
|
||||||
|
|
||||||
|
|
||||||
async def giveaway_premium(
|
|
||||||
client: FragmentClient,
|
|
||||||
channel: str,
|
|
||||||
winners: int,
|
|
||||||
months: int = 3,
|
|
||||||
) -> PremiumGiveawayResult:
|
|
||||||
"""Run a Telegram Premium giveaway for a channel.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
|
||||||
channel: Channel username (with or without ``@``).
|
|
||||||
winners: Number of winners — integer from ``1`` to ``24 000``.
|
|
||||||
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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)},
|
|
||||||
page_url=PREMIUM_GIVEAWAY_PAGE,
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
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,91 +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
|
|
||||||
from pyfragment.utils import get_account_info, process_transaction
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pyfragment.client import FragmentClient
|
|
||||||
|
|
||||||
|
|
||||||
async def giveaway_stars(
|
|
||||||
client: FragmentClient,
|
|
||||||
channel: str,
|
|
||||||
winners: int,
|
|
||||||
amount: int,
|
|
||||||
) -> StarsGiveawayResult:
|
|
||||||
"""Run a Telegram Stars giveaway for a channel.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
|
||||||
channel: Channel username (with or without ``@``).
|
|
||||||
winners: Number of winners — integer from ``1`` to ``5``.
|
|
||||||
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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)},
|
|
||||||
page_url=STARS_GIVEAWAY_PAGE,
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
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,81 +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
|
|
||||||
from pyfragment.utils import get_account_info, process_transaction
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pyfragment.client import FragmentClient
|
|
||||||
|
|
||||||
|
|
||||||
async def purchase_premium(client: FragmentClient, username: str, months: int, show_sender: bool = True) -> PremiumResult:
|
|
||||||
"""Gift Telegram Premium to a user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
|
||||||
username: Recipient's Telegram username (with or without ``@``).
|
|
||||||
months: Premium duration — ``3``, ``6``, or ``12``.
|
|
||||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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}, page_url=PREMIUM_PAGE)
|
|
||||||
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)
|
|
||||||
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,75 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from pyfragment.types import (
|
|
||||||
ConfigurationError,
|
|
||||||
FragmentAPIError,
|
|
||||||
FragmentError,
|
|
||||||
StarsResult,
|
|
||||||
UnexpectedError,
|
|
||||||
UserNotFoundError,
|
|
||||||
VerificationError,
|
|
||||||
)
|
|
||||||
from pyfragment.types.constants import DEVICE, STARS_PAGE
|
|
||||||
from pyfragment.utils import get_account_info, process_transaction
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pyfragment.client import FragmentClient
|
|
||||||
|
|
||||||
|
|
||||||
async def purchase_stars(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> StarsResult:
|
|
||||||
"""Send Telegram Stars to a user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
|
||||||
username: Recipient's Telegram username (with or without ``@``).
|
|
||||||
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``.
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
result = await client.call("initBuyStarsRequest", {"recipient": recipient, "quantity": amount}, page_url=STARS_PAGE)
|
|
||||||
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)
|
|
||||||
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,64 +0,0 @@
|
|||||||
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",
|
|
||||||
]
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any, Literal, get_args
|
|
||||||
|
|
||||||
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
|
||||||
|
|
||||||
# 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 wallet balance required to cover TON network gas fees.
|
|
||||||
MIN_TON_BALANCE: float = 0.056
|
|
||||||
|
|
||||||
# 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-fetch-dest": "empty",
|
|
||||||
"sec-fetch-mode": "cors",
|
|
||||||
"sec-fetch-site": "same-origin",
|
|
||||||
"user-agent": (
|
|
||||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) "
|
|
||||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
|
|
||||||
),
|
|
||||||
"x-requested-with": "XMLHttpRequest",
|
|
||||||
}
|
|
||||||
@@ -1,218 +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
|
|
||||||
balance: float
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"WalletInfo(address='{self.address}', state='{self.state}', balance={self.balance} TON)"
|
|
||||||
|
|
||||||
|
|
||||||
@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,27 +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
|
|
||||||
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",
|
|
||||||
"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,37 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
|
|
||||||
from ton_core import Cell
|
|
||||||
|
|
||||||
from pyfragment.types import ParseError
|
|
||||||
|
|
||||||
|
|
||||||
def clean_decode(payload: str) -> str:
|
|
||||||
"""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, or ``""`` for an empty payload.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ParseError: If the payload cannot be decoded or parsed.
|
|
||||||
"""
|
|
||||||
s = payload.strip()
|
|
||||||
if not s:
|
|
||||||
return ""
|
|
||||||
s += "=" * (-len(s) % 4)
|
|
||||||
try:
|
|
||||||
boc = base64.b64decode(s)
|
|
||||||
cell = Cell.one_from_boc(boc)
|
|
||||||
sl = cell.begin_parse()
|
|
||||||
sl.load_uint(32) # op code — always 0 for text comment
|
|
||||||
return sl.load_snake_string().strip()
|
|
||||||
except Exception as exc:
|
|
||||||
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
|
|
||||||
@@ -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,149 +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.exceptions import ProviderResponseError
|
|
||||||
|
|
||||||
from pyfragment.types import TransactionError, WalletError, WalletInfo
|
|
||||||
from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES
|
|
||||||
from pyfragment.utils.decoder import clean_decode
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pyfragment.client import FragmentClient
|
|
||||||
|
|
||||||
|
|
||||||
async def process_transaction(client: FragmentClient, transaction_data: dict[str, Any]) -> 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``.
|
|
||||||
|
|
||||||
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 transaction amount + gas reserve
|
|
||||||
try:
|
|
||||||
await wallet.refresh()
|
|
||||||
balance_ton = wallet.balance / 1_000_000_000
|
|
||||||
required = amount_ton + MIN_TON_BALANCE
|
|
||||||
if balance_ton < required:
|
|
||||||
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required, gas=MIN_TON_BALANCE))
|
|
||||||
except WalletError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = clean_decode(message["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``, and ``balance`` in TON.
|
|
||||||
|
|
||||||
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()
|
|
||||||
return WalletInfo(
|
|
||||||
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
|
|
||||||
state=wallet.state.value,
|
|
||||||
balance=round(wallet.balance / 1_000_000_000, 4),
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
|
|
||||||
+28
-32
@@ -4,60 +4,45 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pyfragment"
|
name = "pyfragment"
|
||||||
version = "2026.2.1"
|
version = "2026.3.3"
|
||||||
description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts."
|
description = "Async Python client for the Fragment.com marketplace API."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.11"
|
||||||
authors = [{ name = "bohd4nx" }]
|
authors = [{ name = "bohd4nx" }]
|
||||||
keywords = [
|
keywords = [
|
||||||
"fragment",
|
"fragment",
|
||||||
"fragment-api",
|
|
||||||
"telegram",
|
"telegram",
|
||||||
"telegram-stars",
|
"telegram-stars",
|
||||||
"telegram-premium",
|
"telegram-premium",
|
||||||
"ton",
|
"ton",
|
||||||
"tonkeeper",
|
|
||||||
"tonapi",
|
"tonapi",
|
||||||
"crypto",
|
"anonymous-numbers",
|
||||||
"blockchain",
|
|
||||||
"web3",
|
|
||||||
"giveaway",
|
|
||||||
"anonymous-number",
|
|
||||||
"username",
|
|
||||||
"nft",
|
|
||||||
"async",
|
|
||||||
"asyncio",
|
"asyncio",
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"Intended Audience :: Financial and Insurance Industry",
|
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Natural Language :: English",
|
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python",
|
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
"Framework :: AsyncIO",
|
"Framework :: AsyncIO",
|
||||||
"Topic :: Software Development :: Libraries",
|
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
"Topic :: Internet",
|
|
||||||
"Topic :: Internet :: WWW/HTTP",
|
|
||||||
"Topic :: Office/Business :: Financial",
|
|
||||||
"Topic :: Office/Business :: Financial :: Investment",
|
|
||||||
"Typing :: Typed",
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
dependencies = ["httpx>=0.25", "rookiepy>=0.5.6", "tonutils>=2.0.1"]
|
dependencies = ["curl-cffi>=0.14.0", "tonutils>=2.0.1"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
|
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
|
||||||
|
browser = ["rookiepy>=0.5.6"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/bohd4nx/pyfragment"
|
|
||||||
Repository = "https://github.com/bohd4nx/pyfragment"
|
Repository = "https://github.com/bohd4nx/pyfragment"
|
||||||
|
Documentation = "https://bohd4nx.gitbook.io/pyfragment/"
|
||||||
Issues = "https://github.com/bohd4nx/pyfragment/issues"
|
Issues = "https://github.com/bohd4nx/pyfragment/issues"
|
||||||
Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
|
Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
|
||||||
|
|
||||||
@@ -72,20 +57,31 @@ addopts = "-v --tb=short"
|
|||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 128
|
line-length = 128
|
||||||
target-version = "py312"
|
target-version = "py311"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
|
|
||||||
select = ["E", "F", "W", "I", "UP"]
|
select = ["E", "F", "W", "I", "UP"]
|
||||||
# E501 — line too long (covered by line-length above)
|
ignore = ["E501"]
|
||||||
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
|
|
||||||
ignore = ["E501", "UP017"]
|
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"tests/*" = ["E402"]
|
"tests/*" = ["E402"]
|
||||||
"systests/*" = ["E402"]
|
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "3.10"
|
python_version = "3.11"
|
||||||
strict = true
|
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,37 +1,62 @@
|
|||||||
"""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
|
import re
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from ton_core import Cell
|
||||||
|
|
||||||
from pyfragment.types import ParseError
|
from pyfragment import ParseError
|
||||||
from pyfragment.utils.decoder import clean_decode
|
from pyfragment.services.tonapi.transaction import clean_decode
|
||||||
|
|
||||||
PAYLOADS = [
|
PAYLOAD_CASES = [
|
||||||
pytest.param(
|
pytest.param(
|
||||||
"te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ",
|
"te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ",
|
||||||
|
True,
|
||||||
id="stars",
|
id="stars",
|
||||||
),
|
),
|
||||||
pytest.param(
|
pytest.param(
|
||||||
"te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt",
|
"te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt",
|
||||||
|
True,
|
||||||
id="premium",
|
id="premium",
|
||||||
),
|
),
|
||||||
pytest.param(
|
pytest.param(
|
||||||
"te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g",
|
"te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g",
|
||||||
|
True,
|
||||||
id="topup",
|
id="topup",
|
||||||
),
|
),
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAgEAfgABqA-KfqVP885dhccidjC3GwgBCkiH8LM_zUu0afyGCTWJwX1mDjdlf2rMa9UoQlD4UHUAF1jLlcMomlo5RJTwl8jnDDdfdhc7EgQQWPqFQ9IjyLPCAwEASgAAAAA1MCBUZWxlZ3JhbSBTdGFycyAKClJlZiNtOUpoWndBcFE",
|
||||||
|
False,
|
||||||
|
id="real_stars_50",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAgEANgABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAzIG1vbnRocyAKClJlZgEAFCMzcFdKdGJkYnU",
|
||||||
|
False,
|
||||||
|
id="real_premium_3m",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"te6ccgEBAwEAhgABqg-KfqWibdDaYaJCPUWWgvAIAQpIh_CzP81LtGn8hgk1icF9Zg43ZX9qzGvVKEJQ-FB1ABdYy5XDKJpaOUSU8JfI5ww3X3YXOxIEEFj6hUPSI8izwgMBAU4AAAAAMTAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmIzBoZ0RmNEYCAAQ5VA",
|
||||||
|
False,
|
||||||
|
id="real_stars_100k",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Decode valid payload tests
|
# Decode valid payload tests
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("payload", PAYLOADS)
|
@pytest.mark.parametrize(("payload", "strict_ref"), PAYLOAD_CASES)
|
||||||
def test_decode_payload(payload: str) -> None:
|
def test_decode_payload(payload: str, strict_ref: bool) -> None:
|
||||||
result = clean_decode(payload)
|
result = clean_decode(payload)
|
||||||
|
if isinstance(result, str):
|
||||||
assert "Telegram" in result
|
assert "Telegram" in result
|
||||||
|
if strict_ref:
|
||||||
assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}"
|
assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}"
|
||||||
assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}"
|
assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}"
|
||||||
|
else:
|
||||||
|
assert isinstance(result, Cell)
|
||||||
|
|
||||||
|
|
||||||
# Edge case tests
|
# Edge case tests
|
||||||
@@ -44,3 +69,64 @@ def test_empty_payload_returns_empty_string() -> None:
|
|||||||
def test_invalid_payload_raises_parse_error() -> None:
|
def test_invalid_payload_raises_parse_error() -> None:
|
||||||
with pytest.raises(ParseError):
|
with pytest.raises(ParseError):
|
||||||
clean_decode("!!!not-valid-base64!!!")
|
clean_decode("!!!not-valid-base64!!!")
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_payload_accepts_base64url_alphabet() -> None:
|
||||||
|
class _FakeSlice:
|
||||||
|
def load_uint(self, _: int) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def load_snake_string(self) -> str:
|
||||||
|
return "Telegram Stars Ref#abc"
|
||||||
|
|
||||||
|
class _FakeCell:
|
||||||
|
def begin_parse(self) -> _FakeSlice:
|
||||||
|
return _FakeSlice()
|
||||||
|
|
||||||
|
raw = b"\xfb\xef\xff\x00"
|
||||||
|
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
|
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)
|
||||||
|
assert result == "Telegram Stars Ref#abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_decode_returns_text_comment_when_utf8() -> None:
|
||||||
|
class _FakeSlice:
|
||||||
|
def load_uint(self, _: int) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def load_snake_string(self) -> str:
|
||||||
|
return "Telegram Premium Ref#abc"
|
||||||
|
|
||||||
|
class _FakeCell:
|
||||||
|
def begin_parse(self) -> _FakeSlice:
|
||||||
|
return _FakeSlice()
|
||||||
|
|
||||||
|
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
||||||
|
with patch("pyfragment.services.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()):
|
||||||
|
parsed = clean_decode(payload)
|
||||||
|
|
||||||
|
assert parsed == "Telegram Premium Ref#abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_decode_returns_cell_for_binary_payload() -> None:
|
||||||
|
class _FakeSlice:
|
||||||
|
def load_uint(self, _: int) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def load_snake_string(self) -> str:
|
||||||
|
raise UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte")
|
||||||
|
|
||||||
|
class _FakeCell:
|
||||||
|
def begin_parse(self) -> _FakeSlice:
|
||||||
|
return _FakeSlice()
|
||||||
|
|
||||||
|
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
||||||
|
fake_cell: object = _FakeCell()
|
||||||
|
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 json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import ConfigurationError, CookieError, FragmentClient
|
||||||
from pyfragment.types import ConfigurationError, CookieError
|
from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID
|
||||||
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||||
|
|
||||||
# Client init tests
|
# Client init tests
|
||||||
@@ -16,6 +16,30 @@ def test_valid_init() -> None:
|
|||||||
assert client.seed == VALID_SEED.strip()
|
assert client.seed == VALID_SEED.strip()
|
||||||
assert client.api_key == VALID_API_KEY
|
assert client.api_key == VALID_API_KEY
|
||||||
assert client.wallet_version == "V5R1"
|
assert client.wallet_version == "V5R1"
|
||||||
|
assert client.api_provider == "tonapi"
|
||||||
|
|
||||||
|
|
||||||
|
# API provider tests
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_provider_default_is_tonapi() -> None:
|
||||||
|
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
|
assert client.api_provider == "tonapi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_provider_toncenter() -> None:
|
||||||
|
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="toncenter")
|
||||||
|
assert client.api_provider == "toncenter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_provider_is_case_insensitive() -> None:
|
||||||
|
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="TONAPI")
|
||||||
|
assert client.api_provider == "tonapi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_api_provider_raises() -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="infura")
|
||||||
|
|
||||||
|
|
||||||
# Wallet version tests
|
# Wallet version tests
|
||||||
@@ -44,6 +68,11 @@ def test_missing_seed_raises() -> None:
|
|||||||
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_seed_and_api_key_missing_raises() -> None:
|
||||||
|
with pytest.raises(ConfigurationError):
|
||||||
|
FragmentClient(seed="", api_key="", cookies=VALID_COOKIES)
|
||||||
|
|
||||||
|
|
||||||
def test_whitespace_only_seed_raises() -> None:
|
def test_whitespace_only_seed_raises() -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
@@ -56,7 +85,7 @@ def test_invalid_mnemonic_length_raises() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_valid_mnemonic_lengths() -> 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"])
|
seed = " ".join(["abandon"] * (length - 1) + ["about"])
|
||||||
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
assert len(client.seed.split()) == length
|
assert len(client.seed.split()) == length
|
||||||
@@ -70,11 +99,6 @@ def test_missing_api_key_raises() -> None:
|
|||||||
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
|
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
|
||||||
|
|
||||||
|
|
||||||
def test_short_api_key_raises() -> None:
|
|
||||||
with pytest.raises(ConfigurationError):
|
|
||||||
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
|
|
||||||
|
|
||||||
|
|
||||||
# Cookie validation tests
|
# Cookie validation tests
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +134,7 @@ def test_repr() -> None:
|
|||||||
r = repr(client)
|
r = repr(client)
|
||||||
assert "FragmentClient" in r
|
assert "FragmentClient" in r
|
||||||
assert "V5R1" in r
|
assert "V5R1" in r
|
||||||
|
assert "tonapi" in r
|
||||||
assert "4 keys" in r
|
assert "4 keys" in r
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+56
-16
@@ -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 collections.abc import Generator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@@ -7,8 +7,9 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from tonutils.exceptions import ProviderResponseError
|
from tonutils.exceptions import ProviderResponseError
|
||||||
|
|
||||||
from pyfragment.types import TransactionError, WalletError
|
from pyfragment import TransactionError, WalletError
|
||||||
from pyfragment.utils.wallet import process_transaction
|
from pyfragment.enums import PaymentMethod
|
||||||
|
from pyfragment.services.tonapi.transaction import process_transaction
|
||||||
from tests.shared import VALID_SEED
|
from tests.shared import VALID_SEED
|
||||||
|
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ TRANSACTION_DATA = {
|
|||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
|
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
|
||||||
"amount": "500000000", # 0.5 TON
|
"amount": "500000000", # 0.5 GRAM
|
||||||
"payload": "",
|
"payload": "",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -47,12 +48,13 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
||||||
|
mock_ton_ctx = MagicMock()
|
||||||
|
mock_ton_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
|
mock_ton_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
patch("pyfragment.services.tonapi.transaction._make_ton_client", return_value=mock_ton_ctx),
|
||||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
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)
|
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -62,8 +64,8 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sufficient_balance_broadcasts() -> None:
|
async def test_sufficient_balance_broadcasts() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON
|
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 GRAM, above 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)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
assert result == "abc123"
|
||||||
wallet.transfer.assert_called_once()
|
wallet.transfer.assert_called_once()
|
||||||
@@ -71,7 +73,7 @@ async def test_sufficient_balance_broadcasts() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_insufficient_balance_raises() -> None:
|
async def test_insufficient_balance_raises() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON
|
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 GRAM, below threshold
|
||||||
with _patch_wallet(wallet):
|
with _patch_wallet(wallet):
|
||||||
with pytest.raises(WalletError, match="required"):
|
with pytest.raises(WalletError, match="required"):
|
||||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
@@ -80,15 +82,15 @@ async def test_insufficient_balance_raises() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_exact_minimum_balance_broadcasts() -> None:
|
async def test_exact_minimum_balance_broadcasts() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=556_000_000) # exactly 0.5 + 0.056 TON
|
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)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
assert result == "abc123"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_one_nanoton_below_minimum_raises() -> None:
|
async def test_one_nanoton_below_minimum_raises() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton below threshold
|
wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanogram below transaction amount threshold
|
||||||
with _patch_wallet(wallet):
|
with _patch_wallet(wallet):
|
||||||
with pytest.raises(WalletError, match="required"):
|
with pytest.raises(WalletError, match="required"):
|
||||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
@@ -123,7 +125,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
|
|||||||
async def test_rate_limit_retries_and_succeeds() -> None:
|
async def test_rate_limit_retries_and_succeeds() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
||||||
with _patch_wallet(wallet), patch("pyfragment.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)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
assert result == "abc123"
|
||||||
assert wallet.transfer.call_count == 2
|
assert wallet.transfer.call_count == 2
|
||||||
@@ -134,7 +136,45 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
|
|||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
err = _provider_error(406, "Duplicate msg_seqno")
|
err = _provider_error(406, "Duplicate msg_seqno")
|
||||||
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
||||||
with _patch_wallet(wallet), patch("pyfragment.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"):
|
with pytest.raises(TransactionError, match="seqno"):
|
||||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert wallet.transfer.call_count == 3
|
assert wallet.transfer.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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
|
||||||
|
async def test_usdt_payment_checks_usdt_balance() -> None:
|
||||||
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
|
transaction = {
|
||||||
|
"transaction": {
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
|
||||||
|
"amount": "50000000",
|
||||||
|
"payload": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"required_usdt": 12.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
_patch_wallet(wallet),
|
||||||
|
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=PaymentMethod.USDT_GRAM,
|
||||||
|
required_payment_amount=12.5,
|
||||||
|
)
|
||||||
|
|||||||
+220
-19
@@ -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
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
_purchase_stars_mod = importlib.import_module("pyfragment.methods.purchase_stars")
|
import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod
|
||||||
_giveaway_stars_mod = importlib.import_module("pyfragment.methods.giveaway_stars")
|
import pyfragment.domains.purchases.purchase as _purchase_stars_mod
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError
|
||||||
from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError
|
from pyfragment.core.constants import (
|
||||||
|
STARS_GIVEAWAY_MAX,
|
||||||
|
STARS_GIVEAWAY_MIN,
|
||||||
|
STARS_PURCHASE_MAX,
|
||||||
|
STARS_PURCHASE_MIN,
|
||||||
|
STARS_WINNERS_MAX,
|
||||||
|
STARS_WINNERS_MIN,
|
||||||
|
)
|
||||||
|
from pyfragment.enums import PaymentMethod
|
||||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
# Stars purchase validation tests
|
# Stars purchase validation tests
|
||||||
@@ -17,13 +24,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSAC
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None:
|
async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.purchase_stars("@user", amount=49)
|
await client.purchase_stars("@user", amount=STARS_PURCHASE_MIN - 1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None:
|
async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.purchase_stars("@user", amount=1_000_001)
|
await client.purchase_stars("@user", amount=STARS_PURCHASE_MAX + 1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -32,23 +39,27 @@ async def test_purchase_stars_float_amount(client: FragmentClient) -> None:
|
|||||||
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
|
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_invalid_payment_method(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError, match="Invalid payment method"):
|
||||||
|
await client.purchase_stars("@user", amount=500, payment_method="btc") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
# Stars purchase mocked tests
|
# Stars purchase mocked tests
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_purchase_stars_success(client: FragmentClient) -> None:
|
async def test_purchase_stars_success(client: FragmentClient) -> None:
|
||||||
with (
|
call_mock = AsyncMock(
|
||||||
patch.object(
|
|
||||||
client,
|
|
||||||
"call",
|
|
||||||
AsyncMock(
|
|
||||||
side_effect=[
|
side_effect=[
|
||||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updateStarsBuyState
|
||||||
{"req_id": FAKE_REQ_ID},
|
{"req_id": FAKE_REQ_ID},
|
||||||
FAKE_TRANSACTION,
|
FAKE_TRANSACTION,
|
||||||
]
|
]
|
||||||
),
|
)
|
||||||
),
|
with (
|
||||||
|
patch.object(client, "call", call_mock),
|
||||||
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
patch.object(_purchase_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
patch.object(_purchase_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||||
):
|
):
|
||||||
@@ -60,6 +71,44 @@ async def test_purchase_stars_success(client: FragmentClient) -> None:
|
|||||||
assert result.amount == 500
|
assert result.amount == 500
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_passes_payment_method(client: FragmentClient) -> None:
|
||||||
|
call_mock = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updateStarsBuyState
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
|
||||||
|
with (
|
||||||
|
patch.object(client, "call", call_mock),
|
||||||
|
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=PaymentMethod.USDT_GRAM)
|
||||||
|
|
||||||
|
init_call = call_mock.await_args_list[2]
|
||||||
|
assert init_call.args[0] == "initBuyStarsRequest"
|
||||||
|
assert init_call.args[1]["payment_method"] == "usdt_ton"
|
||||||
|
assert proc_mock.await_args is not None
|
||||||
|
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"])
|
||||||
|
async def test_purchase_stars_accepts_query_formats(client: FragmentClient, query: str) -> None:
|
||||||
|
call_mock = AsyncMock(return_value={"found": {}})
|
||||||
|
with patch.object(client, "call", call_mock):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.purchase_stars(query, amount=500)
|
||||||
|
|
||||||
|
search_call = call_mock.await_args_list[0]
|
||||||
|
assert search_call.args[0] == "searchStarsRecipient"
|
||||||
|
assert search_call.args[1]["query"] == query
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
|
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
|
||||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
@@ -73,25 +122,25 @@ async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None:
|
async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.giveaway_stars("@channel", winners=0, amount=500)
|
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN - 1, amount=STARS_GIVEAWAY_MIN)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None:
|
async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.giveaway_stars("@channel", winners=6, amount=500)
|
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MAX + 1, amount=STARS_GIVEAWAY_MIN)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None:
|
async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.giveaway_stars("@channel", winners=1, amount=499)
|
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MIN - 1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None:
|
async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.giveaway_stars("@channel", winners=1, amount=1_000_001)
|
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MAX + 1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -106,6 +155,12 @@ async def test_giveaway_stars_float_amount(client: FragmentClient) -> None:
|
|||||||
await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type]
|
await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_invalid_payment_method(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError, match="Invalid payment method"):
|
||||||
|
await client.giveaway_stars("@channel", winners=1, amount=500, payment_method="btc") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
# Stars giveaway mocked tests
|
# Stars giveaway mocked tests
|
||||||
|
|
||||||
|
|
||||||
@@ -118,6 +173,8 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
|
|||||||
AsyncMock(
|
AsyncMock(
|
||||||
side_effect=[
|
side_effect=[
|
||||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
{"req_id": FAKE_REQ_ID},
|
{"req_id": FAKE_REQ_ID},
|
||||||
FAKE_TRANSACTION,
|
FAKE_TRANSACTION,
|
||||||
]
|
]
|
||||||
@@ -135,8 +192,152 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
|
|||||||
assert result.amount == 1000
|
assert result.amount == 1000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> None:
|
||||||
|
call_mock = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
|
||||||
|
with (
|
||||||
|
patch.object(client, "call", call_mock),
|
||||||
|
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=PaymentMethod.USDT_GRAM)
|
||||||
|
|
||||||
|
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
|
||||||
|
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("query", ["@channel", "monk", "https://t.me/id2757542991"])
|
||||||
|
async def test_giveaway_stars_accepts_query_formats(client: FragmentClient, query: str) -> None:
|
||||||
|
call_mock = AsyncMock(return_value={"found": {}})
|
||||||
|
with patch.object(client, "call", call_mock):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.giveaway_stars(query, winners=1, amount=500)
|
||||||
|
|
||||||
|
search_call = call_mock.await_args_list[0]
|
||||||
|
assert search_call.args[0] == "searchStarsGiveawayRecipient"
|
||||||
|
assert search_call.args[1]["query"] == query
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
|
async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
|
||||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
with pytest.raises(UserNotFoundError):
|
with pytest.raises(UserNotFoundError):
|
||||||
await client.giveaway_stars("@ghost", winners=1, amount=500)
|
await client.giveaway_stars("@ghost", winners=1, amount=500)
|
||||||
|
|
||||||
|
|
||||||
|
# Stars purchase — error branches
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_not_a_user_raises(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"error": "Please enter a username assigned to a user."})):
|
||||||
|
with pytest.raises(UserNotFoundError, match="does not belong"):
|
||||||
|
await client.purchase_stars("@channel", amount=500)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_missing_req_id_raises(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updateStarsBuyState
|
||||||
|
{"amount": "0.1"}, # initBuyStarsRequest — no req_id
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
):
|
||||||
|
from pyfragment.exceptions import FragmentAPIError
|
||||||
|
|
||||||
|
with pytest.raises(FragmentAPIError):
|
||||||
|
await client.purchase_stars("@user", amount=500)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_stars_need_verify_raises(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updateStarsBuyState
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
{"need_verify": True}, # getBuyStarsLink
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
):
|
||||||
|
from pyfragment.exceptions import VerificationError
|
||||||
|
|
||||||
|
with pytest.raises(VerificationError):
|
||||||
|
await client.purchase_stars("@user", amount=500)
|
||||||
|
|
||||||
|
|
||||||
|
# Stars giveaway — error branches
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_missing_req_id_raises(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updateStarsGiveawayState
|
||||||
|
{}, # updateStarsGiveawayPrices
|
||||||
|
{"amount": "0.1"}, # initGiveawayStarsRequest — no req_id
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
):
|
||||||
|
from pyfragment.exceptions import FragmentAPIError
|
||||||
|
|
||||||
|
with pytest.raises(FragmentAPIError):
|
||||||
|
await client.giveaway_stars("@channel", winners=3, amount=1000)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_stars_need_verify_raises(client: FragmentClient) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updateStarsGiveawayState
|
||||||
|
{}, # updateStarsGiveawayPrices
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
{"need_verify": True}, # getGiveawayStarsLink
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
):
|
||||||
|
from pyfragment.exceptions import VerificationError
|
||||||
|
|
||||||
|
with pytest.raises(VerificationError):
|
||||||
|
await client.giveaway_stars("@channel", winners=3, amount=1000)
|
||||||
|
|||||||
+225
-9
@@ -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
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
_purchase_premium_mod = importlib.import_module("pyfragment.methods.purchase_premium")
|
import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod
|
||||||
_giveaway_premium_mod = importlib.import_module("pyfragment.methods.giveaway_premium")
|
import pyfragment.domains.purchases.purchase as _purchase_premium_mod
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError
|
||||||
from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError
|
from pyfragment.core.constants import PREMIUM_MONTHS_VALID, PREMIUM_WINNERS_MAX, PREMIUM_WINNERS_MIN
|
||||||
|
from pyfragment.enums import PaymentMethod
|
||||||
|
from pyfragment.exceptions import AlreadySubscribedError
|
||||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
# Premium purchase validation tests
|
# Premium purchase validation tests
|
||||||
@@ -23,7 +24,13 @@ async def test_purchase_premium_invalid_months(client: FragmentClient) -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
|
async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.purchase_premium("@user", months=0)
|
await client.purchase_premium("@user", months=min(PREMIUM_MONTHS_VALID) - 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_premium_invalid_payment_method(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError, match="Invalid payment method"):
|
||||||
|
await client.purchase_premium("@user", months=3, payment_method="btc") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
# Premium purchase mocked tests
|
# Premium purchase mocked tests
|
||||||
@@ -55,6 +62,63 @@ async def test_purchase_premium_success(client: FragmentClient) -> None:
|
|||||||
assert result.amount == 3
|
assert result.amount == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_premium_passes_payment_method(client: FragmentClient) -> None:
|
||||||
|
call_mock = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updatePremiumState
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
|
||||||
|
with (
|
||||||
|
patch.object(client, "call", call_mock),
|
||||||
|
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=PaymentMethod.USDT_GRAM)
|
||||||
|
|
||||||
|
init_call = call_mock.await_args_list[2]
|
||||||
|
assert init_call.args[0] == "initGiftPremiumRequest"
|
||||||
|
assert init_call.args[1]["payment_method"] == "usdt_ton"
|
||||||
|
assert proc_mock.await_args is not None
|
||||||
|
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:
|
||||||
|
call_mock = AsyncMock(return_value={"found": {}})
|
||||||
|
with patch.object(client, "call", call_mock):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.purchase_premium(query, months=6)
|
||||||
|
|
||||||
|
search_call = call_mock.await_args_list[0]
|
||||||
|
assert search_call.args[0] == "searchPremiumGiftRecipient"
|
||||||
|
assert search_call.args[1]["query"] == query
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
|
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
|
||||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
@@ -68,13 +132,13 @@ async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None:
|
async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.giveaway_premium("@channel", winners=0, months=3)
|
await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MIN - 1, months=3)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None:
|
async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.giveaway_premium("@channel", winners=24_001, months=3)
|
await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MAX + 1, months=3)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -89,6 +153,12 @@ async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None:
|
|||||||
await client.giveaway_premium("@channel", winners=10, months=5)
|
await client.giveaway_premium("@channel", winners=10, months=5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_invalid_payment_method(client: FragmentClient) -> None:
|
||||||
|
with pytest.raises(ConfigurationError, match="Invalid payment method"):
|
||||||
|
await client.giveaway_premium("@channel", winners=10, months=3, payment_method="btc") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
# Premium giveaway mocked tests
|
# Premium giveaway mocked tests
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +171,8 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
|
|||||||
AsyncMock(
|
AsyncMock(
|
||||||
side_effect=[
|
side_effect=[
|
||||||
{"found": {"recipient": FAKE_RECIPIENT}},
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
{"req_id": FAKE_REQ_ID},
|
{"req_id": FAKE_REQ_ID},
|
||||||
FAKE_TRANSACTION,
|
FAKE_TRANSACTION,
|
||||||
]
|
]
|
||||||
@@ -118,8 +190,152 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
|
|||||||
assert result.amount == 3
|
assert result.amount == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> None:
|
||||||
|
call_mock = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
FAKE_TRANSACTION,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
|
||||||
|
with (
|
||||||
|
patch.object(client, "call", call_mock),
|
||||||
|
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=PaymentMethod.USDT_GRAM)
|
||||||
|
|
||||||
|
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
|
||||||
|
assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("query", ["@channel", "monk", "https://t.me/id2757542991"])
|
||||||
|
async def test_giveaway_premium_accepts_query_formats(client: FragmentClient, query: str) -> None:
|
||||||
|
call_mock = AsyncMock(return_value={"found": {}})
|
||||||
|
with patch.object(client, "call", call_mock):
|
||||||
|
with pytest.raises(UserNotFoundError):
|
||||||
|
await client.giveaway_premium(query, winners=10, months=3)
|
||||||
|
|
||||||
|
search_call = call_mock.await_args_list[0]
|
||||||
|
assert search_call.args[0] == "searchPremiumGiveawayRecipient"
|
||||||
|
assert search_call.args[1]["query"] == query
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None:
|
async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None:
|
||||||
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
|
||||||
with pytest.raises(UserNotFoundError):
|
with pytest.raises(UserNotFoundError):
|
||||||
await client.giveaway_premium("@ghost", winners=1, months=3)
|
await client.giveaway_premium("@ghost", winners=1, months=3)
|
||||||
|
|
||||||
|
|
||||||
|
# Premium purchase — error branches
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_premium_not_a_user_raises(client: FragmentClient) -> None:
|
||||||
|
with patch.object(client, "call", AsyncMock(return_value={"error": "Please enter a username assigned to a user."})):
|
||||||
|
with pytest.raises(UserNotFoundError, match="does not belong"):
|
||||||
|
await client.purchase_premium("@channel", months=3)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_premium_missing_req_id_raises(client: FragmentClient) -> None:
|
||||||
|
from pyfragment.exceptions import FragmentAPIError
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updatePremiumState
|
||||||
|
{"amount": "0.1"}, # initGiftPremiumRequest — no req_id
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
):
|
||||||
|
with pytest.raises(FragmentAPIError):
|
||||||
|
await client.purchase_premium("@user", months=3)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_purchase_premium_need_verify_raises(client: FragmentClient) -> None:
|
||||||
|
from pyfragment.exceptions import VerificationError
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updatePremiumState
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
{"need_verify": True}, # getGiftPremiumLink
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
):
|
||||||
|
with pytest.raises(VerificationError):
|
||||||
|
await client.purchase_premium("@user", months=3)
|
||||||
|
|
||||||
|
|
||||||
|
# Premium giveaway — error branches
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_missing_req_id_raises(client: FragmentClient) -> None:
|
||||||
|
from pyfragment.exceptions import FragmentAPIError
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updatePremiumGiveawayState
|
||||||
|
{}, # updatePremiumGiveawayPrices
|
||||||
|
{"amount": "0.1"}, # initGiveawayPremiumRequest — no req_id
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
):
|
||||||
|
with pytest.raises(FragmentAPIError):
|
||||||
|
await client.giveaway_premium("@channel", winners=10, months=3)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_giveaway_premium_need_verify_raises(client: FragmentClient) -> None:
|
||||||
|
from pyfragment.exceptions import VerificationError
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{"found": {"recipient": FAKE_RECIPIENT}},
|
||||||
|
{}, # updatePremiumGiveawayState
|
||||||
|
{}, # updatePremiumGiveawayPrices
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
{"need_verify": True}, # getGiveawayPremiumLink
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
):
|
||||||
|
with pytest.raises(VerificationError):
|
||||||
|
await client.giveaway_premium("@channel", winners=10, months=3)
|
||||||
|
|||||||
+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
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
_topup_ton_mod = importlib.import_module("pyfragment.methods.topup_ton")
|
import pyfragment.domains.ads.tonup as _topup_gram_mod
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
|
||||||
from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError
|
from pyfragment.core.constants import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
|
||||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
# Topup TON validation tests
|
# Topup GRAM (ex TON) validation tests
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.topup_ton("@user", amount=0)
|
await client.topup_gram("@user", amount=GRAM_TOPUP_MIN - 1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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):
|
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
|
@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):
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_topup_ton_success(client: FragmentClient) -> None:
|
async def test_topup_gram_success(client: FragmentClient) -> None:
|
||||||
with (
|
with (
|
||||||
patch.object(
|
patch.object(
|
||||||
client,
|
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_gram_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, "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 isinstance(result, AdsTopupResult)
|
||||||
assert result.transaction_id == FAKE_TX_HASH
|
assert result.transaction_id == FAKE_TX_HASH
|
||||||
@@ -61,7 +60,7 @@ async def test_topup_ton_success(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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(
|
with patch.object(
|
||||||
client,
|
client,
|
||||||
"call",
|
"call",
|
||||||
@@ -73,4 +72,52 @@ async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
|
|||||||
),
|
),
|
||||||
):
|
):
|
||||||
with pytest.raises(UserNotFoundError):
|
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)
|
||||||
|
|||||||
+110
-12
@@ -1,29 +1,30 @@
|
|||||||
"""Unit tests for get_wallet() — wallet address and TON balance lookup."""
|
"""Verify wallet inspection returns friendly GRAM (ex TON) and USDT balances from Tonapi."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pyfragment import FragmentClient, WalletInfo
|
from pyfragment import FragmentClient, WalletInfo
|
||||||
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
|
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOGRAM
|
||||||
|
|
||||||
# Wallet mocked tests
|
# Wallet mocked tests (GRAM and USDT balances are returned separately)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
||||||
mock_wallet = MagicMock()
|
mock_wallet = MagicMock()
|
||||||
mock_wallet.refresh = AsyncMock()
|
mock_wallet.refresh = AsyncMock()
|
||||||
mock_wallet.balance = FAKE_BALANCE_NANOTON
|
mock_wallet.balance = FAKE_BALANCE_NANOGRAM
|
||||||
mock_wallet.state = MagicMock(value="active")
|
mock_wallet.state = MagicMock(value="active")
|
||||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
|
||||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
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.__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)
|
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
|
||||||
|
|
||||||
result = await client.get_wallet()
|
result = await client.get_wallet()
|
||||||
@@ -31,7 +32,8 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
|||||||
assert isinstance(result, WalletInfo)
|
assert isinstance(result, WalletInfo)
|
||||||
assert result.address == FAKE_ADDRESS
|
assert result.address == FAKE_ADDRESS
|
||||||
assert result.state == "active"
|
assert result.state == "active"
|
||||||
assert result.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
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -43,14 +45,110 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
|
|||||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
|
||||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
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.__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)
|
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
|
||||||
|
|
||||||
result = await client.get_wallet()
|
result = await client.get_wallet()
|
||||||
|
|
||||||
assert result.balance == 0.0
|
assert result.gram_balance == 0.0
|
||||||
|
assert result.usdt_balance == 0.0
|
||||||
assert result.state == "uninit"
|
assert result.state == "uninit"
|
||||||
|
|
||||||
|
|
||||||
|
# _make_ton_client — provider selection
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_ton_client_uses_toncenter_for_toncenter_provider(client: FragmentClient) -> None:
|
||||||
|
from tonutils.clients import ToncenterClient
|
||||||
|
|
||||||
|
from pyfragment.enums import ApiProvider
|
||||||
|
from pyfragment.services.tonapi.account import _make_ton_client
|
||||||
|
|
||||||
|
client.api_provider = ApiProvider.TONCENTER
|
||||||
|
result = _make_ton_client(client)
|
||||||
|
assert isinstance(result, ToncenterClient)
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_ton_client_uses_tonapi_for_tonapi_provider(client: FragmentClient) -> None:
|
||||||
|
from tonutils.clients import TonapiClient
|
||||||
|
|
||||||
|
from pyfragment.enums import ApiProvider
|
||||||
|
from pyfragment.services.tonapi.account import _make_ton_client
|
||||||
|
|
||||||
|
client.api_provider = ApiProvider.TONAPI
|
||||||
|
result = _make_ton_client(client)
|
||||||
|
assert isinstance(result, TonapiClient)
|
||||||
|
|
||||||
|
|
||||||
|
# get_usdt_balance — error paths
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_usdt_balance_non_404_provider_error_raises() -> None:
|
||||||
|
from tonutils.exceptions import ProviderResponseError
|
||||||
|
|
||||||
|
from pyfragment import WalletError
|
||||||
|
from pyfragment.services.tonapi.account import get_usdt_balance
|
||||||
|
|
||||||
|
ton = MagicMock()
|
||||||
|
err = ProviderResponseError(code=500, message="server error", endpoint="api.tonapi.io")
|
||||||
|
|
||||||
|
with patch("pyfragment.services.tonapi.account.get_wallet_address_get_method", AsyncMock(side_effect=err)):
|
||||||
|
with pytest.raises(WalletError):
|
||||||
|
await get_usdt_balance(ton, "0:abc")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_usdt_balance_generic_exception_raises() -> None:
|
||||||
|
from pyfragment import WalletError
|
||||||
|
from pyfragment.services.tonapi.account import get_usdt_balance
|
||||||
|
|
||||||
|
ton = MagicMock()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"pyfragment.services.tonapi.account.get_wallet_address_get_method", AsyncMock(side_effect=RuntimeError("timeout"))
|
||||||
|
):
|
||||||
|
with pytest.raises(WalletError):
|
||||||
|
await get_usdt_balance(ton, "0:abc")
|
||||||
|
|
||||||
|
|
||||||
|
# get_account_info / get_wallet_info — exception paths
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_account_info_exception_raises_wallet_error(client: FragmentClient) -> None:
|
||||||
|
from pyfragment import WalletError
|
||||||
|
from pyfragment.services.tonapi.account import get_account_info
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
|
||||||
|
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
|
||||||
|
):
|
||||||
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mock_classes["V5R1"].from_mnemonic.side_effect = RuntimeError("wallet init failed")
|
||||||
|
|
||||||
|
with pytest.raises(WalletError, match="account info"):
|
||||||
|
await get_account_info(client)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_wallet_info_exception_raises_wallet_error(client: FragmentClient) -> None:
|
||||||
|
from pyfragment import WalletError
|
||||||
|
from pyfragment.services.tonapi.account import get_wallet_info
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi,
|
||||||
|
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes,
|
||||||
|
):
|
||||||
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mock_classes["V5R1"].from_mnemonic.side_effect = RuntimeError("key derivation failed")
|
||||||
|
|
||||||
|
with pytest.raises(WalletError, match="wallet info"):
|
||||||
|
await get_wallet_info(client)
|
||||||
|
|||||||
+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
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from curl_cffi.requests import AsyncSession, Response
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import FragmentClient, FragmentPageError
|
||||||
from pyfragment.types import FragmentPageError
|
from pyfragment.core.transport import fragment_request
|
||||||
from pyfragment.utils.http import fragment_request
|
|
||||||
from tests.shared import FAKE_HASH, FAKE_RESPONSE
|
from tests.shared import FAKE_HASH, FAKE_RESPONSE
|
||||||
|
|
||||||
# client.call() mocked tests
|
# client.call() mocked tests
|
||||||
@@ -16,8 +15,8 @@ from tests.shared import FAKE_HASH, FAKE_RESPONSE
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_call_returns_api_response(client: FragmentClient) -> None:
|
async def test_call_returns_api_response(client: FragmentClient) -> None:
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
||||||
):
|
):
|
||||||
result = await client.call("anyMethod", {"key": "value"})
|
result = await client.call("anyMethod", {"key": "value"})
|
||||||
|
|
||||||
@@ -26,10 +25,9 @@ async def test_call_returns_api_response(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_call_default_page_url(client: FragmentClient) -> None:
|
async def test_call_default_page_url(client: FragmentClient) -> None:
|
||||||
"""call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL)."""
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
||||||
):
|
):
|
||||||
result = await client.call("anyMethod")
|
result = await client.call("anyMethod")
|
||||||
|
|
||||||
@@ -38,12 +36,11 @@ async def test_call_default_page_url(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_call_no_data(client: FragmentClient) -> None:
|
async def test_call_no_data(client: FragmentClient) -> None:
|
||||||
"""call() with no extra data passes only the method field."""
|
|
||||||
mock_request = AsyncMock(return_value={})
|
mock_request = AsyncMock(return_value={})
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
patch("pyfragment.client.fragment_request", mock_request),
|
patch("pyfragment.domains.base.fragment_request", mock_request),
|
||||||
):
|
):
|
||||||
await client.call("anyMethod")
|
await client.call("anyMethod")
|
||||||
|
|
||||||
@@ -53,12 +50,11 @@ async def test_call_no_data(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
||||||
"""call() merges caller-supplied data with the method field."""
|
|
||||||
mock_request = AsyncMock(return_value={})
|
mock_request = AsyncMock(return_value={})
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
patch("pyfragment.client.fragment_request", mock_request),
|
patch("pyfragment.domains.base.fragment_request", mock_request),
|
||||||
):
|
):
|
||||||
await client.call("anyMethod", {"key": "value", "num": 7})
|
await client.call("anyMethod", {"key": "value", "num": 7})
|
||||||
|
|
||||||
@@ -71,11 +67,10 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fragment_request_non_200_raises() -> None:
|
async def test_fragment_request_non_200_raises() -> None:
|
||||||
"""fragment_request raises FragmentPageError on non-200 HTTP responses."""
|
response = MagicMock(spec=Response)
|
||||||
response = MagicMock(spec=httpx.Response)
|
|
||||||
response.status_code = 429
|
response.status_code = 429
|
||||||
|
|
||||||
session = AsyncMock(spec=httpx.AsyncClient)
|
session = AsyncMock(spec=AsyncSession)
|
||||||
session.post = AsyncMock(return_value=response)
|
session.post = AsyncMock(return_value=response)
|
||||||
|
|
||||||
with pytest.raises(FragmentPageError, match="429"):
|
with pytest.raises(FragmentPageError, match="429"):
|
||||||
|
|||||||
@@ -1,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
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
_recharge_ads_mod = importlib.import_module("pyfragment.methods.recharge_ads")
|
import pyfragment.domains.ads.recharge as _recharge_ads_mod
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient
|
||||||
from pyfragment.types import AdsRechargeResult, ConfigurationError
|
from pyfragment.core.constants import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
|
||||||
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||||
|
|
||||||
# recharge_ads validation tests
|
# recharge_ads validation tests
|
||||||
@@ -16,13 +15,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANS
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
|
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0)
|
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MIN - 1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
|
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
|
||||||
with pytest.raises(ConfigurationError):
|
with pytest.raises(ConfigurationError):
|
||||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001)
|
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MAX + 1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -56,3 +55,49 @@ async def test_recharge_ads_success(client: FragmentClient) -> None:
|
|||||||
assert isinstance(result, AdsRechargeResult)
|
assert isinstance(result, AdsRechargeResult)
|
||||||
assert result.transaction_id == FAKE_TX_HASH
|
assert result.transaction_id == FAKE_TX_HASH
|
||||||
assert result.amount == 10
|
assert result.amount == 10
|
||||||
|
|
||||||
|
|
||||||
|
# recharge_ads — error branches
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recharge_ads_missing_req_id_raises(client: FragmentClient) -> None:
|
||||||
|
from pyfragment.exceptions import FragmentAPIError
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{}, # updateAdsState
|
||||||
|
{"amount": "0.1"}, # initAdsRechargeRequest — no req_id
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
):
|
||||||
|
with pytest.raises(FragmentAPIError):
|
||||||
|
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recharge_ads_need_verify_raises(client: FragmentClient) -> None:
|
||||||
|
from pyfragment.exceptions import VerificationError
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
client,
|
||||||
|
"call",
|
||||||
|
AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
{}, # updateAdsState
|
||||||
|
{"req_id": FAKE_REQ_ID},
|
||||||
|
{"need_verify": True}, # getAdsRechargeLink
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||||
|
):
|
||||||
|
with pytest.raises(VerificationError):
|
||||||
|
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
|
||||||
|
|||||||
@@ -1,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
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import FragmentClient, UsernamesResult
|
||||||
from pyfragment.types import UsernamesResult
|
|
||||||
|
|
||||||
FAKE_HTML = """
|
FAKE_HTML = """
|
||||||
<tr class="tm-row-selectable">
|
<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
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import FragmentClient, NumbersResult
|
||||||
from pyfragment.types import NumbersResult
|
|
||||||
|
|
||||||
FAKE_HTML = """
|
FAKE_HTML = """
|
||||||
<tr class="tm-row-selectable">
|
<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
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import FragmentClient, GiftsResult
|
||||||
from pyfragment.types import GiftsResult
|
|
||||||
|
|
||||||
FAKE_GIFTS_HTML = """
|
FAKE_GIFTS_HTML = """
|
||||||
<div class="tm-catalog-grid">
|
<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
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pyfragment.types import CookieError
|
from pyfragment import CookieError, get_cookies_from_browser
|
||||||
from pyfragment.types.constants import REQUIRED_COOKIE_KEYS
|
from pyfragment.core.constants import REQUIRED_COOKIE_KEYS
|
||||||
from pyfragment.utils import get_cookies_from_browser
|
|
||||||
|
|
||||||
FAKE_JAR = [
|
FAKE_JAR = [
|
||||||
{"name": "stel_ssid", "value": "abc123", "domain": "fragment.com", "expires": "2027-04-03T20:52:16.375Z"},
|
{"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
|
return mock
|
||||||
|
|
||||||
|
|
||||||
PATCH = "pyfragment.utils.cookies.rookiepy"
|
PATCH = "pyfragment.services.cookies.service.rookiepy"
|
||||||
|
|
||||||
|
|
||||||
# unsupported browser tests
|
# unsupported browser tests
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Parse Fragment init responses to the payment amount the transaction should cover."""
|
||||||
|
|
||||||
|
from pyfragment.domains.payments import parse_required_payment_amount
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_required_payment_amount_ton_uses_amount() -> None:
|
||||||
|
init_response = {"amount": "0.326"}
|
||||||
|
assert parse_required_payment_amount(init_response) == 0.326
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_required_payment_amount_usdt_uses_amount() -> None:
|
||||||
|
init_response = {
|
||||||
|
"amount": "0.00075",
|
||||||
|
"content": '<span class="icon-before icon-usd">0.75</span>',
|
||||||
|
}
|
||||||
|
assert parse_required_payment_amount(init_response) == 0.00075
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_required_payment_amount_usdt_falls_back_to_amount() -> None:
|
||||||
|
init_response = {"amount": "1.25", "content": "<p>no usd icon</p>"}
|
||||||
|
assert parse_required_payment_amount(init_response) == 1.25
|
||||||
+8
-8
@@ -1,22 +1,23 @@
|
|||||||
|
"""Shared pytest fixtures for Fragment client tests."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import pyfragment.methods.giveaway_premium # noqa: F401
|
import pyfragment.domains.ads.recharge # noqa: F401
|
||||||
import pyfragment.methods.giveaway_stars # noqa: F401
|
import pyfragment.domains.ads.tonup # noqa: F401
|
||||||
import pyfragment.methods.purchase_premium # noqa: F401
|
import pyfragment.domains.giveaways.giveaway # noqa: F401
|
||||||
import pyfragment.methods.purchase_stars # noqa: F401
|
import pyfragment.domains.purchases.purchase # noqa: F401
|
||||||
import pyfragment.methods.recharge_ads # noqa: F401
|
import pyfragment.services.tonapi.account # noqa: F401
|
||||||
import pyfragment.methods.topup_ton # noqa: F401
|
import pyfragment.services.tonapi.transaction # noqa: F401
|
||||||
from pyfragment import FragmentClient
|
from pyfragment import FragmentClient
|
||||||
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cookies() -> dict[str, str]:
|
def cookies() -> dict[str, str]:
|
||||||
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
|
|
||||||
raw = os.environ.get("COOKIES_JSON")
|
raw = os.environ.get("COOKIES_JSON")
|
||||||
if not raw:
|
if not raw:
|
||||||
pytest.skip("COOKIES_JSON env var not set")
|
pytest.skip("COOKIES_JSON env var not set")
|
||||||
@@ -28,5 +29,4 @@ def cookies() -> dict[str, str]:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client() -> FragmentClient:
|
def client() -> FragmentClient:
|
||||||
"""Pre-built FragmentClient with dummy credentials."""
|
|
||||||
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
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.
|
"""Shared test constants for the pyfragment test suite.
|
||||||
|
|
||||||
pyfragment is an async Python client for the Fragment API — a unified toolkit
|
pyfragment is an async Python client for the Fragment API — a unified toolkit
|
||||||
to manage Telegram assets: purchase Stars and Premium, top up 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,
|
run giveaways, manage anonymous numbers, and explore the marketplace for usernames,
|
||||||
numbers, and gifts.
|
numbers, and gifts.
|
||||||
"""
|
"""
|
||||||
@@ -31,15 +31,11 @@ FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}}
|
|||||||
|
|
||||||
# get_wallet()
|
# get_wallet()
|
||||||
FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5"
|
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
|
# recharge_ads
|
||||||
FAKE_ADS_ACCOUNT: str = "@mychannel"
|
FAKE_ADS_ACCOUNT: str = "@mychannel"
|
||||||
|
|
||||||
# Revenue withdrawals
|
|
||||||
FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
|
||||||
FAKE_REVENUE_TX: str = "revenue_tx_abc123"
|
|
||||||
|
|
||||||
# Anonymous number
|
# Anonymous number
|
||||||
FAKE_HTML_WITH_CODE: str = """
|
FAKE_HTML_WITH_CODE: str = """
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
Reference in New Issue
Block a user