1 Commits

Author SHA1 Message Date
Bohdan 👾 1a070ce8b9 Merge pull request #13 from bohd4nx/dev
feat: browser cookie extraction, CookieResult type, Python 3.10+ support
2026-04-14 02:00:46 +03:00
105 changed files with 2545 additions and 3822 deletions
+86 -86
View File
@@ -1,101 +1,101 @@
name: Bug report name: Bug report
description: Report an issue or unexpected behavior in pyfragment. description: Report an issue or unexpected behavior in pyfragment.
labels: labels:
- bug - bug
body: body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: Checklist label: Checklist
options: options:
- label: I am sure the error is coming from pyfragment code - label: I am sure the error is coming from pyfragment code
required: true required: true
- label: I have searched the issue tracker for similar bug reports, including closed ones - label: I have searched the issue tracker for similar bug reports, including closed ones
required: true
- type: markdown
attributes:
value: |
## Context
Please provide as much detail as possible to help us reproduce and fix the issue.
- type: input
attributes:
label: Operating system
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
validations:
required: true required: true
- type: markdown - type: input
attributes: attributes:
value: | label: Python version
## Context description: Run `python --version` inside your virtualenv
Please provide as much detail as possible to help us reproduce and fix the issue. placeholder: e.g. 3.12.3
validations:
required: true
- type: input - type: input
attributes: attributes:
label: Operating system label: pyfragment version
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11 description: Run `pip show pyfragment` inside your virtualenv
validations: placeholder: e.g. 2026.1.0
required: true validations:
required: true
- type: input - type: textarea
attributes: attributes:
label: Python version label: Expected behavior
description: Run `python --version` inside your virtualenv description: Describe what you expected to happen.
placeholder: e.g. 3.12.3 placeholder: e.g. Stars should be purchased and StarsResult returned.
validations: validations:
required: true required: true
- type: input - type: textarea
attributes: attributes:
label: pyfragment version label: Current behavior
description: Run `pip show pyfragment` inside your virtualenv description: Describe what is actually happening.
placeholder: e.g. 2026.1.0 placeholder: e.g. ParseError is raised with status 400.
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Expected behavior label: Steps to reproduce
description: Describe what you expected to happen. description: Minimal steps that reproduce the issue.
placeholder: e.g. Stars should be purchased and StarsResult returned. placeholder: |
validations: 1. Create FragmentClient with valid credentials
required: true 2. Call purchase_stars("@username", amount=100)
3. See error
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Current behavior label: Code example
description: Describe what is actually happening. description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
placeholder: e.g. ParseError is raised with status 400. placeholder: |
validations: import asyncio
required: true from pyfragment import FragmentClient
- type: textarea async def main():
attributes: client = FragmentClient(...)
label: Steps to reproduce result = await client.purchase_stars("@username", amount=100)
description: Minimal steps that reproduce the issue.
placeholder: |
1. Create FragmentClient with valid credentials
2. Call purchase_stars("@username", amount=100)
3. See error
validations:
required: true
- type: textarea asyncio.run(main())
attributes: render: python
label: Code example
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
placeholder: |
import asyncio
from pyfragment import FragmentClient
async def main(): - type: textarea
client = FragmentClient(...) attributes:
result = await client.purchase_stars("@username", amount=100) label: Traceback / logs
description: Paste the full traceback or relevant logs.
placeholder: |
Traceback (most recent call last):
File "main.py", line 7, in main
...
pyfragment.types.ParseError: ...
render: sh
asyncio.run(main()) - type: textarea
render: python attributes:
label: Additional information
- type: textarea description: Anything else that might help us diagnose the problem.
attributes: placeholder: e.g. Only happens with V5R1 wallet version.
label: Traceback / logs
description: Paste the full traceback or relevant logs.
placeholder: |
Traceback (most recent call last):
File "main.py", line 7, in main
...
pyfragment.types.ParseError: ...
render: sh
- type: textarea
attributes:
label: Additional information
description: Anything else that might help us diagnose the problem.
placeholder: e.g. Only happens with V5R1 wallet version.
+3 -3
View File
@@ -1,5 +1,5 @@
blank_issues_enabled: true blank_issues_enabled: true
contact_links: contact_links:
- name: Ask a question or start a discussion - name: Ask a question or start a discussion
url: https://github.com/bohd4nx/pyfragment/discussions url: https://github.com/bohd4nx/pyfragment/discussions
about: General questions, ideas, and community help go here — not in the issue tracker. about: General questions, ideas, and community help go here — not in the issue tracker.
+42 -42
View File
@@ -1,51 +1,51 @@
name: Feature request name: Feature request
description: Suggest an improvement or new feature for pyfragment. description: Suggest an improvement or new feature for pyfragment.
labels: labels:
- enhancement - enhancement
body: body:
- type: dropdown - type: dropdown
attributes: attributes:
label: pyfragment version label: pyfragment version
description: Which version are you running? description: Which version are you running?
options: options:
- latest - latest
- older - older
- n/a - n/a
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Problem label: Problem
description: Is your request related to a specific problem? Describe it. description: Is your request related to a specific problem? Describe it.
placeholder: e.g. There is no way to check my current TON balance before sending. placeholder: e.g. There is no way to check my current TON balance before sending.
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Proposed solution label: Proposed solution
description: Describe what you would like to see added or changed. description: Describe what you would like to see added or changed.
placeholder: e.g. Add a get_balance() method to FragmentClient. placeholder: e.g. Add a get_balance() method to FragmentClient.
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Alternatives considered label: Alternatives considered
description: Any workarounds or alternative approaches you have thought of. description: Any workarounds or alternative approaches you have thought of.
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic. placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
- type: textarea - type: textarea
attributes: attributes:
label: Code example label: Code example
description: A short example demonstrating the desired API, if applicable. description: A short example demonstrating the desired API, if applicable.
placeholder: | placeholder: |
balance = await client.get_balance() balance = await client.get_balance()
print(balance.ton) print(balance.ton)
render: python render: python
- type: textarea - type: textarea
attributes: attributes:
label: Additional information label: Additional information
description: Any other context, screenshots, or references. description: Any other context, screenshots, or references.
+16 -16
View File
@@ -1,19 +1,19 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "pip" - package-ecosystem: "pip"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
day: "monday" day: "monday"
open-pull-requests-limit: 5 open-pull-requests-limit: 5
labels: labels:
- "dependencies" - "dependencies"
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
day: "monday" day: "monday"
open-pull-requests-limit: 5 open-pull-requests-limit: 5
labels: labels:
- "dependencies" - "dependencies"
+25 -17
View File
@@ -10,38 +10,46 @@ jobs:
lint: lint:
name: Lint & Format name: Lint & Format
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v6.0.2
- uses: actions/setup-python@v7.0.0 - uses: actions/setup-python@v6.2.0
with: with:
python-version: "3.11" python-version: "3.12"
cache: pip
- run: pip install ".[dev]" - run: pip install ".[dev]"
- run: ruff check . && ruff format --check . && mypy pyfragment --explicit-package-bases - run: ruff check . && black --check . --target-version py312 && mypy pyfragment
test: test:
name: Tests (Python ${{ matrix.python-version }}) name: Tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"] python-version: ["3.10", "3.11", "3.12"] # 3.13, 3.14 are not supported by some dependencies yet
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v6.0.2
- uses: actions/setup-python@v7.0.0 - name: Set up conda (Miniconda)
with: uses: conda-incubator/setup-miniconda@v3
python-version: ${{ matrix.python-version }} with:
cache: pip auto-update-conda: true
python-version: ${{ matrix.python-version }}
activate-environment: pyfragment-test
auto-activate-base: false
- name: Install package and dev dependencies - name: Install package and dev dependencies
run: pip install ".[dev]" shell: bash -el {0}
run: pip install ".[dev]"
- name: Run tests - name: Run tests
run: pytest shell: bash -el {0}
run: pytest
+60 -50
View File
@@ -11,56 +11,62 @@ jobs:
name: Version Check name: Version Check
if: github.event.workflow_run.conclusion == 'success' if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
outputs: outputs:
version: ${{ steps.version.outputs.value }} version: ${{ steps.version.outputs.value }}
is-new: ${{ steps.tag.outputs.is-new }} is-new: ${{ steps.tag.outputs.is-new }}
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Read version - name: Read version
id: version id: version
run: | run: |
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "value=$value" >> $GITHUB_OUTPUT echo "value=$value" >> $GITHUB_OUTPUT
- name: Check tag - name: Check tag
id: tag id: tag
run: | run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "is-new=false" >> $GITHUB_OUTPUT echo "is-new=false" >> $GITHUB_OUTPUT
else else
echo "is-new=true" >> $GITHUB_OUTPUT echo "is-new=true" >> $GITHUB_OUTPUT
fi fi
build: build:
name: Build name: Build
needs: version-check needs: version-check
if: needs.version-check.outputs.is-new == 'true' if: needs.version-check.outputs.is-new == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v6.0.2
- uses: actions/setup-python@v7.0.0 - uses: actions/setup-python@v6.2.0
with: with:
python-version: "3.12" python-version: "3.12"
- uses: astral-sh/setup-uv@v9.0.0 - uses: astral-sh/setup-uv@v7.6.0
- run: uv build - run: uv build
- uses: actions/upload-artifact@v7.0.1 - uses: actions/upload-artifact@v7
with: with:
name: dist name: dist
path: dist/* path: dist/*
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/
@@ -68,40 +74,44 @@ jobs:
id-token: write id-token: write
steps: steps:
- uses: actions/download-artifact@v8.0.1 - uses: actions/download-artifact@v8.0.1
with: with:
name: dist name: dist
path: dist path: dist
- uses: pypa/gh-action-pypi-publish@release/v1 - uses: pypa/gh-action-pypi-publish@v1.13.0
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@v7.0.1 - uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- uses: actions/download-artifact@v8.0.1 - uses: actions/download-artifact@v8.0.1
with: with:
name: dist name: dist
path: dist path: dist
- name: Extract latest changelog entry - name: Extract latest changelog entry
id: changelog id: changelog
run: | run: |
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md) body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "body<<EOF" >> $GITHUB_OUTPUT echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$body" >> $GITHUB_OUTPUT echo "$body" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT
- uses: softprops/action-gh-release@v3.0.2 - uses: softprops/action-gh-release@v2.6.1
with: with:
tag_name: v${{ needs.version-check.outputs.version }} tag_name: v${{ needs.version-check.outputs.version }}
name: v${{ needs.version-check.outputs.version }} name: v${{ needs.version-check.outputs.version }}
files: dist/* files: dist/*
body: ${{ steps.changelog.outputs.body }} body: ${{ steps.changelog.outputs.body }}
make_latest: true make_latest: true
+24 -28
View File
@@ -1,43 +1,39 @@
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.pyc
*$py.class *.pyo
*.so *.pyd
.Python .Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment # Virtual environments
venv/
.venv/ .venv/
venv/
# IDE # IDE
.vscode/
.idea/ .idea/
*.swp .vscode/
*.swo
*~
# Logs # Logs
logs/
*.log *.log
#docs # Environment variables
*-docs/ .env
# OS # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
uv.lock
# Testing & tooling artifacts
.hypothesis/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
systests/
# Build & distribution
dist/
build/
*.egg-info/
+1 -174
View File
@@ -7,169 +7,13 @@ 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
### Fixed
- Fragment API 429 responses are now retried automatically (up to 3 attempts) with exponential backoff and jitter in `fragment_request`
- Retry delays in TON transaction broadcasting now include jitter to reduce contention under concurrent calls
- Improved handling of non-200 HTTP responses in `get_fragment_hash`
- Removed unnecessary `method` key leaking into certain API request payloads
### Changed
- Type hints refined across the codebase for better clarity and `mypy` strict compliance
---
## [2026.2.0] — 2026-04-14 ## [2026.2.0] — 2026-04-14
### Added ### Added
- `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 import get_cookies_from_browser from pyfragment.utils 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
@@ -189,44 +33,37 @@ The TON blockchain has been rebranded to **GRAM (ex TON)**. All identifiers, mes
### Added ### Added
**Giveaways** **Giveaways**
- `giveaway_stars(channel, winners, amount)` — Stars giveaway; 15 winners, 5001 000 000 stars each - `giveaway_stars(channel, winners, amount)` — Stars giveaway; 15 winners, 5001 000 000 stars each
- `giveaway_premium(channel, winners, months)` — Premium giveaway; 124 000 winners, 3/6/12 months each - `giveaway_premium(channel, winners, months)` — Premium giveaway; 124 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; 11 000 000 000 TON - `recharge_ads(account, amount)` — top up a Telegram Ads account; 11 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
@@ -239,18 +76,15 @@ The TON blockchain has been rebranded to **GRAM (ex TON)**. All identifiers, mes
## [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__`
@@ -259,7 +93,6 @@ The TON blockchain has been rebranded to **GRAM (ex TON)**. All identifiers, mes
## [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)
@@ -271,12 +104,6 @@ The TON blockchain has been rebranded to **GRAM (ex TON)**. All identifiers, mes
- `py.typed` marker — full PEP 561 typing support for type-checkers - `py.typed` marker — full PEP 561 typing support for type-checkers
- `__repr__` on all result types for readable debug output - `__repr__` on all result types for readable debug output
[2026.3.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.2
[2026.3.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.1
[2026.3.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.3.0
[2026.2.3]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.3
[2026.2.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.2
[2026.2.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.1
[2026.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0 [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
[2026.0.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2 [2026.0.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2
-58
View File
@@ -1,58 +0,0 @@
# Contributing to pyfragment
## Development setup
```bash
git clone https://github.com/bohd4nx/pyfragment.git
cd pyfragment
pip install -e ".[dev]"
```
## Running checks
```bash
# Lint and format
ruff check . --fix && ruff format .
# Type check
mypy . --explicit-package-bases
# Tests
pytest
```
All three must pass before opening a PR.
## Project structure
```
pyfragment/
client.py — FragmentClient (public entry point)
enums.py — ApiProvider, PaymentMethod, WalletVersion
exceptions.py — exception hierarchy
core/ — constants, validation helpers
domains/ — one package per feature domain
ads/ — recharge_ads, topup_gram
anonymous_numbers/— get_login_code, toggle_login_codes, terminate_sessions
giveaways/ — giveaway_stars, giveaway_premium
marketplace/ — search_usernames, search_numbers, search_gifts
purchases/ — purchase_stars, purchase_premium
services/ — shared infrastructure services
cookies/ — browser cookie extraction (models + service)
tonapi/ — wallet info, transaction signing (tonapi/toncenter)
tests/ — unit tests (pytest)
examples/ — runnable usage examples (excluded from CI)
```
## Conventions
- All public async methods live on `FragmentClient` and delegate to a domain service.
- Domain functions receive a `FragmentClient` instance, never raw HTTP clients.
- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.services.tonapi.transaction._make_ton_client`.
- Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`.
## Pull requests
- Keep PRs focused — one feature or fix per PR.
- Update `CHANGELOG.md` under `[Unreleased]`.
- Add or update tests for any changed behaviour.
+103 -25
View File
@@ -1,21 +1,26 @@
<div align="center"> <div align="center">
<img src="icon.svg" alt="pyfragment" width="96" height="96" style="border-radius: 20px;"><br><br> <img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="Fragment Logo" width="120" height="120" style="border-radius: 24px;">
# pyfragment <h1 style="margin-top: 24px;">Fragment API</h1>
[![PyPI](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue&label=PyPI)](https://pypi.org/project/pyfragment/) <p style="font-size: 18px; margin-bottom: 24px;">
[![Downloads](https://img.shields.io/pepy/dt/pyfragment?style=flat&color=blue&label=Downloads)](https://pepy.tech/projects/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>
[![Python](https://img.shields.io/pypi/pyversions/pyfragment?style=flat&color=blue&label=Python)](https://python.org) </p>
[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=Tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE)
Async Python client for the **[Fragment.com](https://fragment.com)** marketplace API. [![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/)
[![PyPI downloads](https://img.shields.io/pypi/dm/pyfragment?style=flat&color=brightgreen)](https://pypi.org/project/pyfragment/)
[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=lightgrey)](LICENSE)
[![Stars](https://img.shields.io/github/stars/bohd4nx/pyfragment?style=flat&color=yellow)](https://github.com/bohd4nx/pyfragment/stargazers)
[![CI](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** [Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
</div> </div>
> **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org). > **Disclaimer:** This project is not affiliated with, endorsed by, or in any way officially connected with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
---
## Installation ## Installation
@@ -23,43 +28,116 @@ Async Python client for the **[Fragment.com](https://fragment.com)** marketplace
pip install pyfragment pip install pyfragment
``` ```
To install the latest unreleased changes from the `dev` branch:
```bash ```bash
# Latest dev build
pip install git+https://github.com/bohd4nx/pyfragment.git@dev pip install git+https://github.com/bohd4nx/pyfragment.git@dev
``` ```
Requires Python 3.10+.
--- ---
## Quick Start ## Configuration
| Parameter | Type | Default | Description |
| ---------------- | ------------- | -------- | -------------------------------------------------------- |
| `seed` | `str` | — | 24-word TON wallet mnemonic |
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
| `cookies` | `dict \| str` | — | Fragment session cookies |
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
---
## Credentials
**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways:
- **Automatically** (recommended) — 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 (
from pyfragment import FragmentClient FragmentClient,
from pyfragment.enums import PaymentMethod FragmentError, # base — catches everything below
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", seed="word1 word2 ... word24", # 24-word TON wallet mnemonic
api_key="YOUR_API_KEY", api_key="YOUR_TONAPI_KEY", # from tonconsole.com
cookies={"stel_ssid": "...", "stel_dt": "...", "stel_token": "...", "stel_ton_token": "..."}, cookies={
"stel_ssid": "...",
"stel_dt": "...",
"stel_token": "...",
"stel_ton_token": "...",
},
) as client: ) as client:
wallet = await client.get_wallet() try:
print("GRAM: %s | USDT: %s" % (wallet.gram_balance, wallet.usdt_balance)) # Purchase 6 months of Telegram Premium
result = await client.purchase_premium("@username", months=6)
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
stars = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.USDT_GRAM) # Purchase 500 Stars
print("Sent %s Stars to %s | tx: %s" % (stars.amount, stars.username, stars.transaction_id)) result = await client.purchase_stars("@username", amount=500)
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
premium = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM) # Top up 10 TON to Telegram balance
print("Sent Premium %sm to %s | tx: %s" % (premium.amount, premium.username, premium.transaction_id)) # wallet must hold at least amount + ~0.056 TON for gas
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">
[Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) ### Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx)
**Star ⭐ this repo if you found it useful!**
</div> </div>
-19
View File
@@ -1,19 +0,0 @@
# Security Policy
## Reporting a vulnerability
Please **do not** open a public GitHub issue for security vulnerabilities.
Report them privately via GitHub's [Security Advisory](https://github.com/bohd4nx/pyfragment/security/advisories/new) feature, or contact the maintainer directly at [@bohd4nx](https://t.me/bohd4nx) on Telegram.
Include:
- A description of the vulnerability and its potential impact.
- Steps to reproduce or a proof-of-concept.
- Affected versions.
You will receive a response within 72 hours. Once the fix is released, the advisory will be published.
## Scope
This library handles sensitive credentials (GRAM (ex TON) seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity.
+3 -8
View File
@@ -11,9 +11,10 @@ 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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -33,13 +34,7 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER) result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):") print(f"Found {len(result.items)} result(s):")
+3 -8
View File
@@ -10,9 +10,10 @@ 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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -31,13 +32,7 @@ FILTER = "" # "", "auction", "sale", "sold" — or omit
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER) result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):") print(f"Found {len(result.items)} result(s):")
+3 -8
View File
@@ -10,9 +10,10 @@ 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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -31,13 +32,7 @@ FILTER = "auction" # "", "auction", "sale", "sold" — or omit
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER) result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):") print(f"Found {len(result.items)} result(s):")
-33
View File
@@ -1,33 +0,0 @@
"""
Example: extract Fragment cookies directly from your browser.
get_cookies_from_browser() reads the Fragment session cookies from a locally
installed browser — no manual copy-paste required.
Supported browsers: arc, brave, chrome, chromium, chromium_based, edge,
firefox, firefox_based, librewolf, opera, opera_gx,
safari, vivaldi.
The returned CookieResult.cookies dict can be passed directly to FragmentClient.
"""
from pyfragment import CookieError, get_cookies_from_browser
def main() -> None:
try:
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
except CookieError as e:
print(f"Could not read cookies: {e}")
return
print(f"Cookies expire: {result.expires}")
print(f"Keys found: {list(result.cookies.keys())}")
# Pass the extracted cookies directly to FragmentClient
# async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=result.cookies) as client:
# ...
if __name__ == "__main__":
main()
+3 -8
View File
@@ -12,9 +12,10 @@ 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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -33,13 +34,7 @@ PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragmen
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
result = await client.call(METHOD, DATA, page_url=PAGE_URL) result = await client.call(METHOD, DATA, page_url=PAGE_URL)
print(result) print(result)
+5 -7
View File
@@ -1,17 +1,17 @@
""" """
Example: fetch wallet address, state, and separate GRAM (ex TON)/USDT balances. Example: fetch wallet address, state, and balance.
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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -30,14 +30,12 @@ async def main() -> None:
seed=SEED, seed=SEED,
api_key=API_KEY, api_key=API_KEY,
cookies=COOKIES, cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1" wallet_version="V5R1", # or "V4R2"
api_provider="tonapi", # or "toncenter"
) as client: ) as client:
wallet = await client.get_wallet() wallet = await client.get_wallet()
print(f"Address: {wallet.address}") print(f"Address: {wallet.address}")
print(f"State: {wallet.state}") print(f"State: {wallet.state}")
print(f"Balance: {wallet.gram_balance} GRAM (ex TON)") print(f"Balance: {wallet.balance} TON")
print(f"Balance: {wallet.usdt_balance} USDT")
if __name__ == "__main__": if __name__ == "__main__":
+3 -8
View File
@@ -9,9 +9,10 @@ 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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -28,13 +29,7 @@ NUMBER = "+88888888888"
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
# Fetch the latest login code # Fetch the latest login code
result = await client.get_login_code(NUMBER) result = await client.get_login_code(NUMBER)
if result.code: if result.code:
+8 -13
View File
@@ -1,8 +1,8 @@
""" """
Example: recharge your own Telegram Ads account with GRAM (ex TON). Example: recharge your own Telegram Ads account with TON.
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON). Amount must be an integer between 1 and 1 000 000 000 TON.
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost. Your wallet must hold at least the recharge amount + ~0.056 TON for gas.
""" """
import asyncio import asyncio
@@ -13,9 +13,10 @@ 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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -29,17 +30,11 @@ COOKIES = {
} }
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
AMOUNT = 10 # 11 000 000 000 GRAM (ex TON) AMOUNT = 10 # 11 000 000 000 TON
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try: try:
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT) result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
except WalletError as e: except WalletError as e:
@@ -49,7 +44,7 @@ async def main() -> None:
print(f"Invalid argument: {e}") print(f"Invalid argument: {e}")
return return
print(f"{result.amount} GRAM (ex TON) recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}") print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
if __name__ == "__main__": if __name__ == "__main__":
@@ -3,16 +3,15 @@ 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.enums import PaymentMethod from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -25,27 +24,15 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN", "stel_ton_token": "YOUR_STEL_TON_TOKEN",
} }
CHANNEL = "https://t.me/channel" CHANNEL = "@channel"
WINNERS = 10 # 124 000 WINNERS = 10 # 124 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( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try: try:
result = await client.giveaway_premium( result = await client.giveaway_premium(CHANNEL, winners=WINNERS, months=MONTHS)
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
@@ -1,18 +1,17 @@
""" """
Example: run a Telegram Stars giveaway for a channel. Example: run a Telegram Stars giveaway for a channel.
winners must be an integer between 1 and 15. winners must be an integer between 1 and 5.
amount (stars per winner) must be an integer between 500 and 1 000 000. amount (stars per winner) must be an integer between 500 and 1 000 000.
Channel can be "@channel", "channel", or "https://t.me/channel".
""" """
import asyncio import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -25,27 +24,15 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN", "stel_ton_token": "YOUR_STEL_TON_TOKEN",
} }
CHANNEL = "https://t.me/channel" CHANNEL = "@channel"
WINNERS = 3 # 115 WINNERS = 3 # 15
AMOUNT = 1000 # 5001 000 000 stars per winner AMOUNT = 1000 # 5001 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( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try: try:
result = await client.giveaway_stars( result = await client.giveaway_stars(CHANNEL, winners=WINNERS, amount=AMOUNT)
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
@@ -3,16 +3,15 @@ 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.enums import PaymentMethod from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -25,26 +24,14 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN", "stel_ton_token": "YOUR_STEL_TON_TOKEN",
} }
USERNAME = "https://t.me/username" USERNAME = "@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( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try: try:
result = await client.purchase_premium( result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True)
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,18 +1,17 @@
""" """
Example: purchase Telegram Stars for a user. Example: purchase Telegram Stars for a user.
Amount must be an integer between 50 and 10 000 000. Amount must be an integer between 50 and 1 000 000.
Set show_sender=False to send anonymously. Set show_sender=False to send anonymously.
Username can be "@username", "username", or "https://t.me/username".
""" """
import asyncio import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.enums import PaymentMethod from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -25,26 +24,14 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN", "stel_ton_token": "YOUR_STEL_TON_TOKEN",
} }
USERNAME = "https://t.me/username" USERNAME = "@username"
AMOUNT = 500 # 5010 000 000 stars AMOUNT = 500 # 501 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( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try: try:
result = await client.purchase_stars( result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True)
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,10 +1,10 @@
""" """
Example: top up GRAM (ex TON) to a recipient's Telegram balance. Example: top up TON to a recipient's Telegram balance.
For adding GRAM (ex TON) to a Telegram Ads account, use recharge_ads() instead. For adding TON to a Telegram Ads account, use recharge_ads() instead.
Amount must be an integer between 1 and 1 000 000 000 GRAM (ex TON). Amount must be an integer between 1 and 1 000 000 000 TON.
Your wallet must satisfy the current minimum GRAM (ex TON) threshold and transaction cost. Your wallet must hold at least the top-up amount + ~0.056 TON for gas.
""" """
import asyncio import asyncio
@@ -15,9 +15,10 @@ 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_API_KEY" # tonconsole.com (tonapi, default) or t.me/toncenter API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed) # Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
@@ -31,19 +32,13 @@ COOKIES = {
} }
USERNAME = "@username" USERNAME = "@username"
AMOUNT = 10 # 11 000 000 000 GRAM (ex TON) AMOUNT = 10 # 11 000 000 000 TON
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
try: try:
result = await client.topup_gram(USERNAME, amount=AMOUNT, show_sender=True) result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True)
except UserNotFoundError: except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return return
@@ -54,7 +49,7 @@ async def main() -> None:
print(f"Invalid argument: {e}") print(f"Invalid argument: {e}")
return return
print(f"{result.amount} GRAM (ex TON) successfully topped up for {result.username} | tx: {result.transaction_id}") print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}")
if __name__ == "__main__": if __name__ == "__main__":
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512" height="512" style="width: 100%; height: 100%; transform: translate3d(0,0,0); content-visibility: visible;" preserveAspectRatio="xMidYMid meet"><defs><clipPath id="__lottie_element_2"><rect width="512" height="512" x="0" y="0"></rect></clipPath><clipPath id="__lottie_element_4"><path d="M0,0 L100,0 L100,100 L0,100z"></path></clipPath></defs><g clip-path="url(#__lottie_element_2)"><g clip-path="url(#__lottie_element_4)" style="display: block;" transform="matrix(5.119999885559082,0,0,5.119999885559082,0,0)" opacity="1"><g style="display: block;" transform="matrix(1.333299994468689,0,0,1.333299994468689,0,0)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(30,40,51)" fill-opacity="1" d=" M47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 27.68000030517578,5.619999885559082 27.68000030517578,5.619999885559082 C15.5,5.619999885559082 5.619999885559082,15.5 5.619999885559082,27.68000030517578 C5.619999885559082,27.68000030517578 5.619999885559082,47.31999969482422 5.619999885559082,47.31999969482422 C5.619999885559082,59.5 15.5,69.37999725341797 27.68000030517578,69.37999725341797 C27.68000030517578,69.37999725341797 47.31999969482422,69.37999725341797 47.31999969482422,69.37999725341797 C59.5,69.37999725341797 69.37999725341797,59.5 69.37999725341797,47.31999969482422 C69.37999725341797,47.31999969482422 69.37999725341797,27.68000030517578 69.37999725341797,27.68000030517578 C69.37999725341797,15.5 59.5,5.619999885559082 47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082z"></path></g><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(255,255,255)" fill-opacity="1" d=" M36.349998474121094,32.79999923706055 C36.349998474121094,32.79999923706055 21.1299991607666,25.940000534057617 21.1299991607666,25.940000534057617 C20.020000457763672,25.450000762939453 20.389999389648438,23.790000915527344 21.600000381469727,23.790000915527344 C21.600000381469727,23.790000915527344 53.41999816894531,23.790000915527344 53.41999816894531,23.790000915527344 C54.630001068115234,23.790000915527344 54.9900016784668,25.440000534057617 53.880001068115234,25.940000534057617 C53.880001068115234,25.940000534057617 38.66999816894531,32.79999923706055 38.66999816894531,32.79999923706055 C37.939998626708984,33.130001068115234 37.09000015258789,33.130001068115234 36.36000061035156,32.79999923706055 C36.36000061035156,32.79999923706055 36.349998474121094,32.79999923706055 36.349998474121094,32.79999923706055z M56.81999969482422,30.06999969482422 C57.43000030517578,29.1200008392334 56.43000030517578,27.979999542236328 55.400001525878906,28.440000534057617 C55.400001525878906,28.440000534057617 40.72999954223633,35.13999938964844 40.72999954223633,35.13999938964844 C39.72999954223633,35.599998474121094 39.09000015258789,36.61000061035156 39.09000015258789,37.70000076293945 C39.09000015258789,37.70000076293945 39.09000015258789,53.810001373291016 39.09000015258789,53.810001373291016 C39.09000015258789,54.93000030517578 40.54999923706055,55.36000061035156 41.15999984741211,54.41999816894531 C41.15999984741211,54.41999816894531 56.810001373291016,30.06999969482422 56.810001373291016,30.06999969482422 C56.810001373291016,30.06999969482422 56.81999969482422,30.06999969482422 56.81999969482422,30.06999969482422z M19.600000381469727,28.440000534057617 C18.579999923706055,27.979999542236328 17.56999969482422,29.1200008392334 18.18000030517578,30.06999969482422 C18.18000030517578,30.06999969482422 33.84000015258789,54.43000030517578 33.84000015258789,54.43000030517578 C34.45000076293945,55.380001068115234 35.90999984741211,54.939998626708984 35.90999984741211,53.81999969482422 C35.90999984741211,53.81999969482422 35.90999984741211,37.70000076293945 35.90999984741211,37.70000076293945 C35.90999984741211,36.599998474121094 35.27000045776367,35.599998474121094 34.27000045776367,35.13999938964844 C34.27000045776367,35.13999938964844 19.59000015258789,28.450000762939453 19.59000015258789,28.450000762939453 C19.59000015258789,28.450000762939453 19.600000381469727,28.440000534057617 19.600000381469727,28.440000534057617z"></path></g></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

+35 -37
View File
@@ -1,73 +1,71 @@
import logging # Copyright (c) 2026 bohd4nx
#
# This source code is licensed under the MIT License found in the
# LICENSE file in the root directory of this source tree.
from importlib.metadata import version from importlib.metadata import version
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult from pyfragment.types import (
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult AdsRechargeResult,
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult AdsTopupResult,
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",
# results
"StarsResult",
"StarsGiveawayResult",
"PremiumResult",
"PremiumGiveawayResult",
"WalletInfo",
"AdsTopupResult",
"AdsRechargeResult", "AdsRechargeResult",
"CookieResult", "AdsTopupResult",
"GiftsResult", "GiftsResult",
"LoginCodeResult", "LoginCodeResult",
"NumbersResult", "NumbersResult",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult", "TerminateSessionsResult",
"UsernamesResult", "UsernamesResult",
# exceptions "WalletInfo",
"FragmentError",
"FragmentAPIError",
"FragmentPageError",
"ConfigurationError",
"AlreadySubscribedError",
"UserNotFoundError",
"WalletError",
"VerificationError",
"TransactionError",
"AnonymousNumberError",
"ClientError", "ClientError",
"ConfigurationError",
"CookieError", "CookieError",
"CookieResult",
"FragmentAPIError",
"FragmentError",
"FragmentPageError",
"AnonymousNumberError",
"OperationError", "OperationError",
"ParseError", "ParseError",
"TransactionError",
"UnexpectedError", "UnexpectedError",
# literal types "UserNotFoundError",
"ApiProvider", "VerificationError",
"PaymentMethod", "WalletError",
"WalletVersion",
"get_cookies_from_browser",
] ]
+162 -122
View File
@@ -1,29 +1,45 @@
from __future__ import annotations from __future__ import annotations
from typing import Any import json
from typing import Any, cast
from pyfragment.core.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL import httpx
from pyfragment.core.validation import (
normalize_provider, from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
normalize_wallet_version, from pyfragment.methods.giveaway_premium import giveaway_premium
parse_cookies, from pyfragment.methods.giveaway_stars import giveaway_stars
validate_cookie_keys, from pyfragment.methods.purchase_premium import purchase_premium
validate_credentials, from pyfragment.methods.purchase_stars import purchase_stars
from pyfragment.methods.recharge_ads import recharge_ads
from pyfragment.methods.search_gifts import search_gifts
from pyfragment.methods.search_numbers import search_numbers
from pyfragment.methods.search_usernames import search_usernames
from pyfragment.methods.topup_ton import topup_ton
from pyfragment.types import (
AdsRechargeResult,
AdsTopupResult,
ConfigurationError,
CookieError,
GiftsResult,
LoginCodeResult,
NumbersResult,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
StarsResult,
TerminateSessionsResult,
UsernamesResult,
WalletInfo,
) )
from pyfragment.domains.ads.models import AdsRechargeResult, AdsTopupResult from pyfragment.types.constants import (
from pyfragment.domains.ads.service import AdsService DEFAULT_TIMEOUT,
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult FRAGMENT_BASE_URL,
from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService REQUIRED_COOKIE_KEYS,
from pyfragment.domains.base import raw_api_call SUPPORTED_WALLET_VERSIONS,
from pyfragment.domains.giveaways.models import PremiumGiveawayResult, StarsGiveawayResult WalletVersion,
from pyfragment.domains.giveaways.service import GiveawaysService )
from pyfragment.domains.marketplace.models import GiftsResult, NumbersResult, UsernamesResult from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
from pyfragment.domains.marketplace.service import MarketplaceService from pyfragment.utils.wallet import get_wallet_info
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:
@@ -35,18 +51,14 @@ class FragmentClient:
connected with Fragment or Telegram. connected with Fragment or Telegram.
Args: Args:
seed: 12- or 24-word mnemonic phrase for the GRAM (ex TON) wallet. seed: 24-word mnemonic phrase for the TON wallet.
api_key: API key for the chosen provider — tonconsole.com (default) or t.me/toncenter. api_key: Tonapi API key — get one at https://tonconsole.com.
cookies: Fragment session cookies as a dict or JSON string. cookies: Fragment session cookies as a dict or JSON string.
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
api_provider: Blockchain API provider — ``"tonapi"`` (tonconsole.com, default)
or ``"toncenter"`` (t.me/toncenter).
timeout: HTTP request timeout in seconds. Defaults to ``30.0``. timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
headers: Custom HTTP request headers. If omitted, :data:`BASE_HEADERS` is used.
Raises: Raises:
ConfigurationError: If ``seed``, ``api_key``, ``wallet_version``, or ``api_provider`` ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
are missing or invalid.
CookieError: If ``cookies`` cannot be parsed or are missing required keys. CookieError: If ``cookies`` cannot be parsed or are missing required keys.
Example:: Example::
@@ -65,165 +77,164 @@ class FragmentClient:
self, self,
seed: str, seed: str,
api_key: str, api_key: str,
cookies: dict[str, Any] | str, cookies: dict | str,
wallet_version: str = "V5R1", wallet_version: str = "V5R1",
api_provider: str = "tonapi",
timeout: float = DEFAULT_TIMEOUT, timeout: float = DEFAULT_TIMEOUT,
headers: dict[str, str] | None = None,
) -> None: ) -> None:
validate_credentials(seed, api_key) missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
provider = normalize_provider(api_provider) if missing:
parsed_cookies = parse_cookies(cookies) raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
validate_cookie_keys(parsed_cookies)
version = normalize_wallet_version(wallet_version) word_count = len(seed.split())
if word_count not in (12, 18, 24):
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
if len(api_key.strip()) < 68:
raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
if isinstance(cookies, str):
try:
cookies = json.loads(cookies)
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict, 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.api_provider: ApiProvider = provider self.cookies: dict = cast(dict, cookies)
self.cookies: dict[str, Any] = parsed_cookies self.wallet_version: WalletVersion = version # type: ignore[assignment]
self.wallet_version: WalletVersion = version
self.timeout: float = timeout self.timeout: float = timeout
self.headers: dict[str, str] = headers if headers is not None else BASE_HEADERS
self.marketplace = MarketplaceService(self)
self.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
async def __aexit__(self, *_: object) -> None: async def __aexit__(self, *_: object) -> None:
pass pass
def __repr__(self) -> str: def __repr__(self) -> str:
return f"FragmentClient(wallet_version='{self.wallet_version}', api_provider='{self.api_provider}', cookies={len(self.cookies)} keys)" return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)"
async def purchase_premium( async def purchase_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult:
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 identifier — ``@username``, ``username``, or ``https://t.me/username``. username: Recipient's Telegram username (with or without ``@``).
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 self.purchases.purchase_premium(username, months, show_sender=show_sender, payment_method=payment_method) return await purchase_premium(self, username, months, show_sender)
async def purchase_stars( async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult:
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 identifier — ``@username``, ``username``, or ``https://t.me/username``. username: Recipient's Telegram username (with or without ``@``).
amount: Number of stars — integer from ``50`` to ``10 000 000``. amount: Number of stars — integer from ``50`` to ``1 000 000``.
show_sender: Show your name as the gift sender. Defaults to ``True``. show_sender: Show your name as the gift sender. Defaults to ``True``.
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
Returns: Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
""" """
return await self.purchases.purchase_stars(username, amount, show_sender=show_sender, payment_method=payment_method) return await purchase_stars(self, username, amount, show_sender)
async def topup_gram(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up GRAM (ex TON) to a recipient's Telegram balance. """Top up TON to a recipient's Telegram balance.
Args: Args:
username: Recipient's Telegram username (with or without ``@``). username: Recipient's Telegram username (with or without ``@``).
amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``. amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
show_sender: Show your name as the sender. Defaults to ``True``. show_sender: Show your name as the sender. Defaults to ``True``.
Returns: Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``. :class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
""" """
return await self.ads.topup_gram(username, amount, show_sender=show_sender) return await topup_ton(self, username, amount, show_sender)
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account. """Add funds to your own Telegram Ads account.
Args: Args:
account: Channel or bot username the Ads account is linked to (e.g. ``"@mychannel"``). account: Your Fragment Ads account identifier — the channel or bot username
amount: Amount in GRAM (ex TON) — integer from ``1`` to ``1 000 000 000``. the Ads account is linked to (e.g. ``"@mychannel"``).
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
Returns: Returns:
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``. :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
""" """
return await self.ads.recharge_ads(account, amount) return await recharge_ads(self, account, amount)
async def get_wallet(self) -> WalletInfo: async def get_wallet(self) -> WalletInfo:
"""Return the address, state, and balances of the wallet. """Return the address, state and balance of the TON wallet.
Returns: Returns:
:class:`WalletInfo` with ``address``, ``state``, ``gram_balance``, and ``usdt_balance``. :class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON.
""" """
return await self.tonapi.get_wallet() return await get_wallet_info(self)
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 identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. channel: Channel username (with or without ``@``).
winners: Number of winners — integer from ``1`` to ``15``. winners: Number of winners — integer from ``1`` to ``5``.
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
Returns: Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``. :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
""" """
return await self.giveaways.giveaway_stars(channel, winners, amount, payment_method=payment_method) return await giveaway_stars(self, channel, winners, amount)
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 identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. channel: Channel username (with or without ``@``).
winners: Number of winners — integer from ``1`` to ``24 000``. winners: Number of winners — positive integer.
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``. months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
payment_method: Payment currency — defaults to ``PaymentMethod.GRAM``.
Returns: Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, ``winners``, and ``amount``. :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
""" """
return await self.giveaways.giveaway_premium(channel, winners, months, payment_method=payment_method) return await giveaway_premium(self, channel, winners, months)
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 ``+``. number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
Returns: Returns:
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending), :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
and ``active_sessions`` count. and ``active_sessions`` count.
""" """
return await self.anonymous_numbers.get_login_code(number) return await get_login_code(self, 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.
@@ -232,7 +243,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 self.anonymous_numbers.toggle_login_codes(number, can_receive) return await toggle_login_codes(self, 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.
@@ -244,9 +255,9 @@ class FragmentClient:
:class:`TerminateSessionsResult` with ``number`` and ``message``. :class:`TerminateSessionsResult` with ``number`` and ``message``.
Raises: Raises:
AnonymousNumberError: If the number is not owned or has no active sessions. AnonymousNumberError: If the number is not owned by this account or has no active sessions.
""" """
return await self.anonymous_numbers.terminate_sessions(number) return await terminate_sessions(self, number)
async def search_usernames( async def search_usernames(
self, self,
@@ -258,15 +269,19 @@ class FragmentClient:
"""Search the Fragment marketplace for Telegram usernames. """Search the Fragment marketplace for Telegram usernames.
Args: Args:
query: Search text. Omit or pass ``""`` to browse all. query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``. sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available). ``"ending"``. Omit to use Fragment's default ordering.
offset_id: Pass :attr:`UsernamesResult.next_offset_id` to fetch the next page. filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id`
from a previous result to fetch the next page.
Returns: Returns:
:class:`UsernamesResult` with ``items`` and ``next_offset_id``. :class:`UsernamesResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
""" """
return await self.marketplace.search_usernames(query, sort=sort, filter=filter, offset_id=offset_id) return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id)
async def search_numbers( async def search_numbers(
self, self,
@@ -278,15 +293,19 @@ class FragmentClient:
"""Search the Fragment marketplace for anonymous Telegram numbers. """Search the Fragment marketplace for anonymous Telegram numbers.
Args: Args:
query: Search text. Omit or pass ``""`` to browse all. query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``. sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available). ``"ending"``. Omit to use Fragment's default ordering.
offset_id: Pass :attr:`NumbersResult.next_offset_id` to fetch the next page. filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id`
from a previous result to fetch the next page.
Returns: Returns:
:class:`NumbersResult` with ``items`` and ``next_offset_id``. :class:`NumbersResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
""" """
return await self.marketplace.search_numbers(query, sort=sort, filter=filter, offset_id=offset_id) return await search_numbers(self, query, sort=sort, filter=filter, offset_id=offset_id)
async def search_gifts( async def search_gifts(
self, self,
@@ -301,19 +320,25 @@ class FragmentClient:
"""Search the Fragment gifts marketplace. """Search the Fragment gifts marketplace.
Args: Args:
query: Search text. Omit or pass ``""`` to browse all. query: Search text. Omit or pass ``""`` to browse without filtering by name.
collection: Gift collection slug (e.g. ``"artisanbrick"``). collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
sort: ``"price_desc"``, ``"price_asc"``, ``"listed"``, or ``"ending"``. sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
filter: ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` (available). ``"ending"``. Omit to use Fragment's default ordering.
view: Active attribute tab name (e.g. ``"Model"``). filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
attr: Attribute filters — e.g. ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}``. ``""`` (available items). Omit to return all.
offset: Pass :attr:`GiftsResult.next_offset` to fetch the next page. view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
attr: Attribute filters — mapping of trait name to accepted values, e.g.
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
Each key is sent as ``attr[Key]`` with its list of values.
offset: Integer page offset from a previous :class:`GiftsResult`.
Pass ``next_offset`` to fetch the next page.
Returns: Returns:
:class:`GiftsResult` with ``items`` and ``next_offset``. :class:`GiftsResult` with ``items`` (parsed list of item dicts)
and ``next_offset`` (``None`` on the last page).
""" """
return await self.marketplace.search_gifts( return await search_gifts(
query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
) )
async def call( async def call(
@@ -321,12 +346,27 @@ class FragmentClient:
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Send a raw request to the Fragment API. """Send a raw request to the Fragment API.
Useful for accessing undocumented or future Fragment API methods
without waiting for a library update.
Args: Args:
method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``. method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``.
data: Additional form-data fields. data: Additional form-data fields to include in the request body.
page_url: Fragment page URL to derive the API hash. Defaults to ``FRAGMENT_BASE_URL``. page_url: Fragment page URL used to derive the API hash and headers.
Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``).
Returns: Returns:
Raw parsed JSON response as a dict. Raw parsed JSON response as a dict.
Example::
result = await client.call(
"searchPremiumGiftRecipient",
{"query": "@username", "months": 3},
page_url="https://fragment.com/premium/gift",
)
""" """
return await raw_api_call(self.cookies, self.timeout, method, data, page_url, self.headers) headers = make_headers(page_url)
async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session:
fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout)
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
View File
-83
View File
@@ -1,83 +0,0 @@
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})
-67
View File
@@ -1,67 +0,0 @@
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"))
-58
View File
@@ -1,58 +0,0 @@
from __future__ import annotations
import json
from typing import Any, cast
from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID, REQUIRED_COOKIE_KEYS
from pyfragment.enums import ApiProvider, WalletVersion
from pyfragment.exceptions import ConfigurationError, CookieError
def parse_cookies(cookies: dict[str, Any] | str) -> dict[str, Any]:
if isinstance(cookies, str):
try:
cookies = json.loads(cookies)
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
return cast(dict[str, Any], cookies)
def validate_cookie_keys(cookies: dict[str, Any]) -> None:
missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
if missing:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing)))
def normalize_provider(api_provider: str) -> ApiProvider:
try:
return ApiProvider(api_provider.strip().lower())
except ValueError:
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_PROVIDER.format(
provider=api_provider,
supported=", ".join(sorted(p.value for p in ApiProvider)),
)
)
def normalize_wallet_version(wallet_version: str) -> WalletVersion:
version = wallet_version.strip().upper()
try:
return WalletVersion(version)
except ValueError:
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_VERSION.format(
version=version,
supported=", ".join(sorted(m.value for m in WalletVersion)),
)
)
def validate_credentials(seed: str, api_key: str) -> None:
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
if missing:
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
word_count = len(seed.split())
if word_count not in MNEMONIC_WORD_COUNTS_VALID:
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
-1
View File
@@ -1 +0,0 @@
"""Domain-level helpers for Fragment operations."""
-5
View File
@@ -1,5 +0,0 @@
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"]
-22
View File
@@ -1,22 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class AdsTopupResult:
transaction_id: str
username: str
amount: int
def __repr__(self) -> str:
return f"AdsTopupResult(username='{self.username}', amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')"
@dataclass
class AdsRechargeResult:
transaction_id: str
amount: int
def __repr__(self) -> str:
return f"AdsRechargeResult(amount={self.amount} GRAM (ex TON), tx='{self.transaction_id}')"
-54
View File
@@ -1,54 +0,0 @@
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN
from pyfragment.domains.ads.models import AdsRechargeResult
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
from pyfragment.services.tonapi.account import get_account_info
from pyfragment.services.tonapi.transaction import process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX):
raise ConfigurationError(ConfigurationError.INVALID_GRAM_AMOUNT)
try:
await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
result = await client.call("initAdsRechargeRequest", {"account": account, "amount": amount}, page_url=ADS_TOPUP_PAGE)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge"))
account_info = await get_account_info(client)
transaction = await client.call(
"getAdsRechargeLink",
{
"account": json.dumps(account_info),
"device": json.dumps(DEVICE_INFO),
"transaction": 1,
"id": req_id,
},
page_url=ADS_TOPUP_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
except FragmentError as exc:
logger.error("Failed to recharge Ads account '%s' for %s GRAM (ex TON): %s", account, amount, exc, exc_info=True)
raise
except Exception as exc:
logger.exception("Failed to recharge Ads account '%s' for %s GRAM (ex TON) due to an unexpected error", account, amount)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-19
View File
@@ -1,19 +0,0 @@
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,12 +0,0 @@
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",
]
@@ -1,23 +0,0 @@
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})"
@@ -1,13 +0,0 @@
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
@@ -1,21 +0,0 @@
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)
-42
View File
@@ -1,42 +0,0 @@
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
-11
View File
@@ -1,11 +0,0 @@
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",
]
-242
View File
@@ -1,242 +0,0 @@
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
-31
View File
@@ -1,31 +0,0 @@
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}')"
)
-31
View File
@@ -1,31 +0,0 @@
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)
@@ -1,13 +0,0 @@
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",
]
-31
View File
@@ -1,31 +0,0 @@
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})"
-148
View File
@@ -1,148 +0,0 @@
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
-44
View File
@@ -1,44 +0,0 @@
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
)
-11
View File
@@ -1,11 +0,0 @@
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
-5
View File
@@ -1,5 +0,0 @@
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"]
-23
View File
@@ -1,23 +0,0 @@
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}')"
-209
View File
@@ -1,209 +0,0 @@
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
-31
View File
@@ -1,31 +0,0 @@
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)
-54
View File
@@ -1,54 +0,0 @@
from __future__ import annotations
from enum import StrEnum
from typing import Any
from tonutils.contracts.wallet import WalletHighloadV2, WalletHighloadV3R1, WalletV4R2, WalletV5R1
class PaymentMethod(StrEnum):
GRAM = "ton"
USDT_GRAM = "usdt_ton"
# Not supported yet
USDT_ETH = "usdt_eth"
USDT_POL = "usdt_pol"
USDC_ETH = "usdc_eth"
USDC_BASE = "usdc_base"
USDC_POL = "usdc_pol"
class WalletVersion(StrEnum):
V4R2 = "V4R2"
V5R1 = "V5R1"
HighloadV2 = "HighloadV2"
HighloadV3R1 = "HighloadV3R1"
WALLET_CLASSES: dict[WalletVersion, Any] = {
WalletVersion.V4R2: WalletV4R2,
WalletVersion.V5R1: WalletV5R1,
WalletVersion.HighloadV2: WalletHighloadV2,
WalletVersion.HighloadV3R1: WalletHighloadV3R1,
}
class ApiProvider(StrEnum):
TONAPI = "tonapi" # tonconsole.com — default
TONCENTER = "toncenter" # t.me/toncenter
class SupportedBrowser(StrEnum):
ARC = "arc"
BRAVE = "brave"
CHROME = "chrome"
CHROMIUM = "chromium"
CHROMIUM_BASED = "chromium_based"
EDGE = "edge"
FIREFOX = "firefox"
FIREFOX_BASED = "firefox_based"
LIBREWOLF = "librewolf"
OPERA = "opera"
OPERA_GX = "opera_gx"
SAFARI = "safari"
VIVALDI = "vivaldi"
-179
View File
@@ -1,179 +0,0 @@
from __future__ import annotations
from pyfragment.core.constants import (
GRAM_TOPUP_MAX,
GRAM_TOPUP_MIN,
MNEMONIC_WORD_COUNTS_VALID,
PREMIUM_MONTHS_VALID,
PREMIUM_WINNERS_MAX,
PREMIUM_WINNERS_MIN,
STARS_GIVEAWAY_MAX,
STARS_GIVEAWAY_MIN,
STARS_PURCHASE_MAX,
STARS_PURCHASE_MIN,
STARS_WINNERS_MAX,
STARS_WINNERS_MIN,
)
class FragmentError(Exception):
"""Base exception for all pyfragment errors."""
class ClientError(FragmentError):
"""Raised for client configuration and setup issues."""
class ConfigurationError(ClientError):
"""Raised when required client parameters are missing or invalid."""
MISSING_VARS = "Missing required parameter(s): {keys}."
UNSUPPORTED_VERSION = "Unsupported wallet version '{version}'. Supported values: {supported}."
INVALID_MNEMONIC = f"Invalid mnemonic phrase: expected {', '.join(str(n) for n in sorted(MNEMONIC_WORD_COUNTS_VALID))} words, got {{count}}."
UNSUPPORTED_PROVIDER = "Unsupported API provider '{provider}'. Supported values: {supported}."
INVALID_MONTHS = f"Invalid Premium duration: choose {', '.join(str(m) for m in sorted(PREMIUM_MONTHS_VALID))} months."
INVALID_STARS_AMOUNT = (
f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}."
)
INVALID_GRAM_AMOUNT = f"Invalid GRAM (ex TON) amount: must be an integer between {GRAM_TOPUP_MIN:,} and {GRAM_TOPUP_MAX:,}."
INVALID_WINNERS_STARS = (
f"Invalid winners count: must be an integer between {STARS_WINNERS_MIN:,} and {STARS_WINNERS_MAX:,}."
)
INVALID_WINNERS_PREMIUM = (
f"Invalid winners count: must be an integer between {PREMIUM_WINNERS_MIN:,} and {PREMIUM_WINNERS_MAX:,}."
)
INVALID_STARS_PER_WINNER = (
f"Invalid Stars per winner: must be an integer between {STARS_GIVEAWAY_MIN:,} and {STARS_GIVEAWAY_MAX:,}."
)
INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
class CookieError(ClientError):
"""Raised when cookies are unreadable or missing required fields."""
READ_FAILED = "Failed to parse cookies: expected a JSON string or a dict, got {exc}."
MISSING_KEYS = (
"Fragment cookies are missing or empty for key(s): {keys}. "
"Open fragment.com in your browser, log in, and copy fresh cookies."
)
UNSUPPORTED_BROWSER = "Unsupported browser '{browser}'. Supported values: {supported}."
BROWSER_READ_FAILED = (
"Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}."
)
MISSING_BROWSER_KEYS = (
"Fragment cookies not found in {browser}: {keys}. "
"Make sure you are logged in to {url} and have connected your 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."
class FragmentAPIError(FragmentError):
"""Raised for errors returned by Fragment's API responses."""
NO_REQUEST_ID = "Fragment did not return a request ID for '{context}'. Your session may have expired. Refresh your cookies and try again."
class FragmentPageError(FragmentAPIError):
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
BAD_STATUS = "Fragment returned HTTP {status} when loading {url}. Your cookies may be invalid or expired. Refresh them and try again."
NOT_FOUND = "Could not extract the API hash from {url}. The page structure may have changed, or you may not be logged in. Refresh your cookies."
class UserNotFoundError(FragmentAPIError):
"""Raised when the target Telegram user is not found on Fragment."""
NOT_FOUND = (
"Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
)
NOT_A_USER = "'{username}' does not belong to a user account. Make sure the username is assigned to a personal Telegram account, not a channel or bot."
class AlreadySubscribedError(FragmentAPIError):
"""Raised when trying to gift Premium to a user who already has an active subscription."""
PREMIUM_ACTIVE = "This account is already subscribed to Telegram Premium."
class AnonymousNumberError(FragmentAPIError):
"""Raised for Fragment anonymous number API failures."""
NOT_OWNED = "Number '{number}' is not associated with your Fragment account or has no active sessions to terminate."
TERMINATE_FAILED = "Failed to terminate sessions for '{number}': {error}"
class TransactionError(FragmentAPIError):
"""Raised when a GRAM (ex TON) transaction fails to build or broadcast."""
INVALID_PAYLOAD = "Fragment returned an invalid transaction payload: 'transaction.messages' is missing or empty."
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
BROADCAST_FAILED_SSL = (
"Transaction broadcast failed due to an SSL certificate error: {exc}\n"
"This usually means your system's CA bundle is missing or outdated.\n"
"Fix: run `pip install --upgrade certifi` and retry. "
"On macOS you may also need to run the 'Install Certificates.command' "
"located in your Python installation folder."
)
DUPLICATE_SEQNO = (
"Transaction broadcast failed: the GRAM (ex TON) wallet rejected the message "
"because a previous transaction with the same sequence number (seqno) "
"is still pending confirmation on-chain.\n"
"Wait a few seconds for the previous transaction to confirm, then retry."
)
class ParseError(FragmentAPIError):
"""Raised when a Fragment API response or payload cannot be parsed."""
UNPARSEABLE = "Failed to parse the Fragment API response for '{context}': {exc}"
class VerificationError(FragmentAPIError):
"""Raised when Fragment requires KYC verification before proceeding."""
KYC_REQUIRED = (
"Fragment requires identity verification (KYC) before this action can be completed. "
"Complete verification at https://fragment.com/my/profile and retry."
)
class OperationError(FragmentError):
"""Raised for runtime operation failures unrelated to Fragment's API."""
class WalletError(OperationError):
"""Raised for GRAM (ex TON) wallet issues (connection, balance, account info)."""
LOW_GRAM_BALANCE = (
"Insufficient GRAM (ex TON) balance: {balance:.4f} GRAM (ex TON) available, {required:.4f} GRAM (ex TON) required."
)
LOW_USDT_BALANCE = "Insufficient USDT balance: {balance:.4f} USDT available, {required:.4f} USDT required."
GRAM_BALANCE_CHECK_FAILED = "Failed to fetch GRAM (ex TON) balance: {exc}"
USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from GRAM (ex TON) network: {exc}"
WALLET_INFO_FAILED = "Failed to retrieve wallet info from GRAM (ex TON) network: {exc}"
class UnexpectedError(OperationError):
"""Raised when an unexpected error occurs during an API call."""
UNEXPECTED = "An unexpected error occurred during the operation: {exc}"
__all__ = [
"FragmentError",
"ClientError",
"ConfigurationError",
"CookieError",
"FragmentAPIError",
"FragmentPageError",
"AnonymousNumberError",
"AlreadySubscribedError",
"UserNotFoundError",
"TransactionError",
"ParseError",
"VerificationError",
"OperationError",
"WalletError",
"UnexpectedError",
]
+25
View File
@@ -0,0 +1,25 @@
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,26 +1,42 @@
from __future__ import annotations from __future__ import annotations
import html import html
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.core.constants import NUMBERS_PAGE from pyfragment.types import (
from pyfragment.domains.anonymous_numbers.models import LoginCodeResult, TerminateSessionsResult AnonymousNumberError,
from pyfragment.domains.anonymous_numbers.parser import parse_login_code FragmentAPIError,
from pyfragment.exceptions import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError FragmentError,
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(
@@ -36,15 +52,24 @@ async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult
return LoginCodeResult(number=number, code=code, active_sessions=active_sessions) return LoginCodeResult(number=number, code=code, active_sessions=active_sessions)
except FragmentError as exc: except FragmentError:
logger.error("Failed to get login code for number '%s': %s", number, exc, exc_info=True)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to get login code for number '%s' due to an unexpected error", number)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
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(
@@ -56,25 +81,31 @@ async def toggle_login_codes(client: FragmentClient, number: str, can_receive: b
if result.get("error"): if result.get("error"):
raise FragmentAPIError(html.unescape(result["error"])) raise FragmentAPIError(html.unescape(result["error"]))
except FragmentError as exc: except FragmentError:
logger.error(
"Failed to toggle login code delivery for number '%s' (can_receive=%s): %s",
number,
can_receive,
exc,
exc_info=True,
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to toggle login code delivery for number '%s' (can_receive=%s) due to an unexpected error",
number,
can_receive,
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
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)
@@ -106,9 +137,7 @@ async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSe
return TerminateSessionsResult(number=number, message=result.get("msg")) return TerminateSessionsResult(number=number, message=result.get("msg"))
except FragmentError as exc: except FragmentError:
logger.error("Failed to terminate sessions for number '%s': %s", number, exc, exc_info=True)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("Failed to terminate sessions for number '%s' due to an unexpected error", number)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+95
View File
@@ -0,0 +1,95 @@
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 124 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
+91
View File
@@ -0,0 +1,91 @@
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 15 or ``amount`` is not 5001 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
+81
View File
@@ -0,0 +1,81 @@
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
+75
View File
@@ -0,0 +1,75 @@
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
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from pyfragment.types import (
AdsRechargeResult,
ConfigurationError,
FragmentAPIError,
FragmentError,
UnexpectedError,
VerificationError,
)
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account.
Args:
client: Authenticated :class:`FragmentClient` instance.
account: Your Fragment Ads account identifier the channel or bot username
the Ads account is linked to (e.g. ``"@mychannel"``).
amount: Amount in TON integer from ``1`` to ``1 000 000 000``.
Returns:
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
Raises:
ConfigurationError: If ``amount`` is not a valid integer in the allowed range.
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
try:
await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
result = await client.call("initAdsRechargeRequest", {"account": account, "amount": amount}, page_url=ADS_TOPUP_PAGE)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge"))
account_info = await get_account_info(client)
transaction = await client.call(
"getAdsRechargeLink",
{
"account": json.dumps(account_info),
"device": DEVICE,
"transaction": 1,
"id": req_id,
},
page_url=ADS_TOPUP_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+75
View File
@@ -0,0 +1,75 @@
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] = {"method": "searchAuctions", "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
+62
View File
@@ -0,0 +1,62 @@
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] = {"method": "searchAuctions", "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
+62
View File
@@ -0,0 +1,62 @@
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] = {"method": "searchAuctions", "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
@@ -1,13 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE_INFO, GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from pyfragment.types import (
from pyfragment.domains.ads.models import AdsTopupResult AdsTopupResult,
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.exceptions import (
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
FragmentError, FragmentError,
@@ -15,19 +12,33 @@ from pyfragment.exceptions import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
) )
from pyfragment.services.tonapi.account import get_account_info from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.services.tonapi.transaction import process_transaction 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 topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""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``.
async def topup_gram(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: Returns:
if not isinstance(amount, int) or not (GRAM_TOPUP_MIN <= amount <= GRAM_TOPUP_MAX): :class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
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)
@@ -38,17 +49,16 @@ async def topup_gram(client: FragmentClient, username: str, amount: int, show_se
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="GRAM (ex TON) topup")) raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
account = await get_account_info(client) account = await get_account_info(client)
transaction = await client.call( transaction = await client.call(
"getAdsTopupLink", "getAdsTopupLink",
{ {
"account": json.dumps(account), "account": json.dumps(account),
"device": json.dumps(DEVICE_INFO), "device": DEVICE,
"transaction": 1, "transaction": 1,
"id": req_id, "id": req_id,
"show_sender": int(show_sender), "show_sender": int(show_sender),
@@ -58,16 +68,10 @@ async def topup_gram(client: FragmentClient, username: str, amount: int, show_se
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, required_payment_amount=required_payment_amount) tx_hash = await process_transaction(client, transaction)
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount) return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError as exc: except FragmentError:
logger.error(
"Failed to top up GRAM (ex TON) for user '%s' with %s GRAM (ex TON): %s", username, amount, exc, exc_info=True
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception(
"Failed to top up GRAM (ex TON) for user '%s' with %s GRAM (ex TON) due to an unexpected error", username, amount
)
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-3
View File
@@ -1,3 +0,0 @@
from pyfragment.services.cookies import CookieResult, get_cookies_from_browser
__all__ = ["CookieResult", "get_cookies_from_browser"]
-4
View File
@@ -1,4 +0,0 @@
from pyfragment.services.cookies.models import CookieResult
from pyfragment.services.cookies.service import get_cookies_from_browser
__all__ = ["CookieResult", "get_cookies_from_browser"]
-12
View File
@@ -1,12 +0,0 @@
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})"
-60
View File
@@ -1,60 +0,0 @@
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)
-5
View File
@@ -1,5 +0,0 @@
from pyfragment.services.tonapi.service import TonapiService
__all__ = [
"TonapiService",
]
-135
View File
@@ -1,135 +0,0 @@
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
-17
View File
@@ -1,17 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class WalletInfo:
address: str
state: str
gram_balance: float
usdt_balance: float
def __repr__(self) -> str:
return (
f"WalletInfo(address='{self.address}', state='{self.state}', "
f"gram_balance={self.gram_balance} GRAM (ex TON), usdt_balance={self.usdt_balance} USDT)"
)
-15
View File
@@ -1,15 +0,0 @@
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)
-168
View File
@@ -1,168 +0,0 @@
from __future__ import annotations
import asyncio
import base64
import logging
import random
import ssl
from typing import TYPE_CHECKING, Any
from ton_core import Cell
from tonutils.exceptions import ProviderResponseError
from pyfragment.enums import WALLET_CLASSES, PaymentMethod
from pyfragment.exceptions import ParseError, TransactionError, WalletError
from pyfragment.services.tonapi.account import _make_ton_client, check_gram_payment_balance, check_usdt_payment_balance
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
logger = logging.getLogger(__name__)
def clean_decode(payload: str) -> str | Cell:
"""Decode a base64 BOC comment from Fragment into text when possible.
Some Fragment payloads are plain text comments, while others are structured
GRAM (ex TON) messages such as jetton transfers. Non-text payloads are returned as a
`Cell` so the caller can keep the raw binary structure.
"""
s = payload.strip()
if not s:
return ""
s += "=" * (-len(s) % 4)
try:
boc = base64.b64decode(s, altchars=b"-_", validate=True)
cell = Cell.one_from_boc(boc)
sl = cell.begin_parse()
op = sl.load_uint(32)
if op != 0:
# Non-zero op code means this is a structured GRAM (ex TON) message, not a plain text comment.
return cell
try:
return sl.load_snake_string().strip()
except UnicodeDecodeError:
return cell
except Exception as exc:
logger.exception("Failed to decode Fragment payload")
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
def _extract_message(transaction_data: dict[str, Any]) -> dict[str, Any]:
"""Validate and extract the first message from a Fragment transaction payload."""
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
logger.error("Failed to process transaction: missing transaction payload or messages")
raise TransactionError(TransactionError.INVALID_PAYLOAD)
result: dict[str, Any] = transaction_data["transaction"]["messages"][0]
return result
async def _check_payment_balances(
wallet: Any,
payment_method: PaymentMethod,
amount_gram: float,
required_payment_amount: float | None,
transaction_data: dict[str, Any],
ton: Any,
) -> None:
"""Refresh wallet and verify sufficient balance before broadcasting."""
try:
await wallet.refresh()
balance_gram = wallet.balance / 1_000_000_000
if payment_method == "ton":
await check_gram_payment_balance(balance_gram, amount_gram, required_payment_amount)
else:
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_gram, required_payment_amount, ton, fragment_wallet_address)
except WalletError:
raise
except Exception as exc:
logger.exception("Failed to validate balances before broadcasting transaction")
raise WalletError(WalletError.GRAM_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def _broadcast_with_retry(wallet: Any, message: dict[str, Any], payload: str | Cell) -> str:
"""Attempt to broadcast a transaction up to 3 times, handling rate-limit and seqno errors."""
for attempt in range(3):
try:
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanograms, not GRAM (ex TON)
body=payload,
)
return str(result.normalized_hash)
except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0:
logger.warning(
"Broadcast rate-limited (429), retrying transaction once: %s",
exc,
exc_info=True,
)
await asyncio.sleep(1 + random.uniform(0, 0.5))
continue
if exc.code == 406 and "seqno" in str(exc).lower():
if attempt < 2:
logger.warning(
"Broadcast seqno conflict (406), retrying attempt %s: %s",
attempt + 2,
exc,
exc_info=True,
)
await asyncio.sleep(2 + random.uniform(0, 1))
continue
logger.error("Failed to broadcast transaction after seqno retries")
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
raise
logger.error("Failed to broadcast transaction: transfer loop exited without result")
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
async def process_transaction(
client: FragmentClient,
transaction_data: dict[str, Any],
payment_method: PaymentMethod = PaymentMethod.GRAM,
required_payment_amount: float | None = None,
) -> str:
"""Sign and broadcast a Fragment transaction with the seeded GRAM (ex TON) wallet.
Args:
client: Authenticated `FragmentClient` instance.
transaction_data: Raw Fragment transaction payload returned by the API.
payment_method: Payment currency to use for the purchase flow.
required_payment_amount: Optional amount returned by Fragment's init request.
Returns:
Normalized transaction hash string.
"""
message = _extract_message(transaction_data)
amount_gram = int(message["amount"]) / 1_000_000_000
async with _make_ton_client(client) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await _check_payment_balances(wallet, payment_method, amount_gram, required_payment_amount, transaction_data, ton)
payload = clean_decode(str(message.get("payload", "")))
try:
return await _broadcast_with_retry(wallet, message, payload)
except (WalletError, TransactionError):
raise
except Exception as exc:
cause: BaseException | None = exc
while cause is not None:
if isinstance(cause, ssl.SSLError):
logger.exception("Failed to broadcast transaction due to SSL error")
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
cause = cause.__cause__ or cause.__context__
logger.exception(
"Failed to broadcast transaction to '%s' for %s nanograms using payment method '%s'",
message["address"],
message["amount"],
payment_method,
)
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
+64
View File
@@ -0,0 +1,64 @@
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",
]
+85
View File
@@ -0,0 +1,85 @@
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",
}
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
class FragmentError(Exception):
"""Base exception for all pyfragment library errors."""
class ClientError(FragmentError):
"""Raised for client configuration and setup issues (bad params, invalid cookies)."""
class ConfigurationError(ClientError):
"""Raised when required client parameters are missing or invalid."""
MISSING_VARS = "Missing required parameter(s): {keys}."
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
INVALID_API_KEY = (
"Invalid Tonapi API key: expected at least 68 characters, got {length}. " "Generate a key at https://tonconsole.com."
)
INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months."
INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000."
INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1 000 000 000."
INVALID_USERNAME = (
"Invalid username '{username}'. "
"Must be 532 characters and contain only letters (AZ, az), digits (09), or underscores (_)."
)
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000."
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000."
class CookieError(ClientError):
"""Raised when cookies are unreadable or missing required fields."""
READ_FAILED = "Failed to parse cookies — expected a JSON string or a dict, got: {exc}"
MISSING_KEYS = (
"Fragment cookies are missing or empty for key(s): {keys}. "
"Open fragment.com in your browser, log in, and copy fresh cookies."
)
UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}."
BROWSER_READ_FAILED = (
"Failed to read {browser} cookies: {exc}. " "Make sure {browser} is installed and you are logged in to {url}."
)
MISSING_BROWSER_KEYS = (
"Fragment cookies not found in {browser}: {keys}. "
"Make sure you are logged in to {url} and have connected your TON wallet in {browser}."
)
class FragmentAPIError(FragmentError):
"""Raised for errors returned by Fragment's API responses."""
NO_REQUEST_ID = (
"Fragment did not return a request ID for '{context}'. "
"Your session may have expired — log in to fragment.com and refresh your cookies."
)
class FragmentPageError(FragmentAPIError):
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
BAD_STATUS = (
"Fragment returned HTTP {status} when loading {url}. "
"Your cookies may be invalid or expired — log in to fragment.com and refresh them."
)
NOT_FOUND = (
"Could not extract the API hash from {url}. "
"The page structure may have changed, or you are not logged in — refresh your cookies."
)
class UserNotFoundError(FragmentAPIError):
"""Raised when the target Telegram user is not found on Fragment."""
NOT_FOUND = (
"Telegram user '{username}' was not found on Fragment. " "Double-check the username and make sure the account exists."
)
class AnonymousNumberError(FragmentAPIError):
"""Raised for Fragment anonymous number API failures."""
NOT_OWNED = "Number '{number}' is not associated with your Fragment account or has no active sessions to terminate."
TERMINATE_FAILED = "Failed to terminate sessions for '{number}': {error}"
class TransactionError(FragmentAPIError):
"""Raised when a TON transaction fails to build or broadcast."""
INVALID_PAYLOAD = (
"Fragment returned an invalid transaction payload — " "'transaction.messages' is missing or empty in the API response."
)
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
BROADCAST_FAILED_SSL = (
"Transaction broadcast failed due to an SSL certificate error: {exc}\n"
"This usually means your system's CA bundle is missing or outdated.\n"
"Fix: run `pip install --upgrade certifi` and retry. "
"On macOS you may also need to run the 'Install Certificates.command' "
"located in your Python installation folder."
)
DUPLICATE_SEQNO = (
"Transaction broadcast failed: the TON wallet rejected the message "
"because a previous transaction with the same sequence number (seqno) "
"is still pending confirmation on-chain.\n"
"Wait a few seconds for the previous transaction to confirm, then retry."
)
class ParseError(FragmentAPIError):
"""Raised when a Fragment API response or payload cannot be parsed."""
UNPARSEABLE = "Failed to parse the Fragment API response for '{context}': {exc}"
class VerificationError(FragmentAPIError):
"""Raised when Fragment requires KYC verification before proceeding."""
KYC_REQUIRED = (
"Fragment requires identity verification (KYC) before this action can be completed. "
"Complete verification at https://fragment.com/my/profile and retry."
)
class OperationError(FragmentError):
"""Raised for runtime operation failures unrelated to Fragment's API."""
class WalletError(OperationError):
"""Raised for TON wallet issues (connection, balance, account info)."""
LOW_BALANCE = (
"Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required "
"(transaction amount + {gas:.3f} TON gas reserve)."
)
BALANCE_CHECK_FAILED = "Failed to fetch wallet balance: {exc}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}"
WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
class UnexpectedError(OperationError):
"""Raised when an unexpected error occurs during an API call."""
UNEXPECTED = "An unexpected error occurred during the operation: {exc}"
__all__ = [
"FragmentError",
"ClientError",
"ConfigurationError",
"CookieError",
"FragmentAPIError",
"FragmentPageError",
"AnonymousNumberError",
"UserNotFoundError",
"TransactionError",
"ParseError",
"VerificationError",
"OperationError",
"WalletError",
"UnexpectedError",
]
+218
View File
@@ -0,0 +1,218 @@
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",
]
+27
View File
@@ -0,0 +1,27 @@
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",
]
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from datetime import datetime, timezone
import rookiepy
from pyfragment.types import CookieError, 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] = 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
return CookieResult(
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
expires=expires_iso,
)
+37
View File
@@ -0,0 +1,37 @@
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
@@ -3,14 +3,22 @@ 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">([^<]+)<')
@@ -20,7 +28,39 @@ 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)
@@ -28,17 +68,21 @@ 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("/") slug = href_m.group(1).lstrip("/") # e.g. "username/durov"
# All tm-value spans in the row — first is the display name
values = [m.group(1).strip() for m in VALUE_RE.finditer(row)] 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:
@@ -48,15 +92,38 @@ 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({"slug": slug, "name": name, "status": status, "price": price, "date": date}) items.append(
{
"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)
@@ -64,7 +131,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("/") slug = href_m.group(1).lstrip("/") # e.g. "gift/plushpepe-1821"
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)
@@ -89,6 +156,7 @@ 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
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import re
from typing import Any
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 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.
"""
resp = await session.post(
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
headers=headers,
data=data,
)
return parse_json_response(resp, data.get("method", "request"))
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
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import asyncio
import base64
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 "messages" not in transaction_data["transaction"]:
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 result.normalized_hash
except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0:
await asyncio.sleep(1)
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)
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
+33 -44
View File
@@ -4,45 +4,48 @@ build-backend = "hatchling.build"
[project] [project]
name = "pyfragment" name = "pyfragment"
version = "2026.3.3" version = "2026.2.0"
description = "Async Python client for the Fragment.com marketplace API." 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."
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
requires-python = ">=3.11" requires-python = ">=3.10"
authors = [{ name = "bohd4nx" }] authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }]
keywords = [ keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"]
"fragment",
"telegram",
"telegram-stars",
"telegram-premium",
"ton",
"tonapi",
"anonymous-numbers",
"asyncio",
]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"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 :: Python Modules", "Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet",
"Topic :: Office/Business :: Financial",
"Typing :: Typed", "Typing :: Typed",
] ]
dependencies = ["curl-cffi>=0.14.0", "tonutils>=2.0.1"] dependencies = [
"httpx==0.28.1",
"rookiepy==0.5.6",
"tonutils==2.1.0",
]
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"] dev = [
browser = ["rookiepy>=0.5.6"] "pytest==9.0.3",
"pytest-asyncio==1.3.0",
"pytest-mock",
"mypy",
"ruff",
"black",
]
[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"
@@ -50,38 +53,24 @@ Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
packages = ["pyfragment"] packages = ["pyfragment"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
python_files = ["[0-9][0-9][0-9]_test_*.py"] python_files = ["[0-9][0-9][0-9]_test_*.py"]
asyncio_mode = "auto" asyncio_mode = "auto"
addopts = "-v --tb=short" addopts = "-v --tb=short"
[tool.black]
line-length = 128
target-version = ["py312"]
[tool.ruff] [tool.ruff]
line-length = 128 line-length = 128
target-version = "py311" target-version = "py312"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "W", "I", "UP"] # E — pycodestyle errors, F — pyflakes, W — warnings, I — isort
select = ["E", "F", "W", "I"]
ignore = ["E501"] ignore = ["E501"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"] "tests/*" = ["E402"]
"systests/*" = ["E402"]
[tool.mypy]
python_version = "3.11"
strict = true
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
View File
@@ -0,0 +1 @@
-e .[dev]
+1
View File
@@ -0,0 +1 @@
-e .
+9 -95
View File
@@ -1,62 +1,37 @@
"""Decode Fragment BOC payloads so comments become text and structured messages stay raw.""" """Tests for clean_decode() — TON BOC payload decoding."""
import base64
import re import re
from unittest.mock import patch
import pytest import pytest
from ton_core import Cell
from pyfragment import ParseError from pyfragment.types import ParseError
from pyfragment.services.tonapi.transaction import clean_decode from pyfragment.utils.decoder import clean_decode
PAYLOAD_CASES = [ PAYLOADS = [
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", "strict_ref"), PAYLOAD_CASES) @pytest.mark.parametrize("payload", PAYLOADS)
def test_decode_payload(payload: str, strict_ref: bool) -> None: def test_decode_payload(payload: str) -> None:
result = clean_decode(payload) result = clean_decode(payload)
if isinstance(result, str): assert "Telegram" in result
assert "Telegram" in result assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}"
if strict_ref: assert all(ord(c) < 128 for c in result), f"non-ASCII chars 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}"
else:
assert isinstance(result, Cell)
# Edge case tests # Edge case tests
@@ -69,64 +44,3 @@ 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
+9 -34
View File
@@ -1,11 +1,11 @@
"""Validate FragmentClient setup, cookie parsing, and wallet version checks.""" """Unit tests for FragmentClient — initialization, validation, and cookie parsing."""
import json import json
import pytest import pytest
from pyfragment import ConfigurationError, CookieError, FragmentClient from pyfragment import FragmentClient
from pyfragment.core.constants import MNEMONIC_WORD_COUNTS_VALID from pyfragment.types import ConfigurationError, CookieError
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
# Client init tests # Client init tests
@@ -16,30 +16,6 @@ def test_valid_init() -> None:
assert client.seed == VALID_SEED.strip() assert client.seed == VALID_SEED.strip()
assert client.api_key == VALID_API_KEY assert client.api_key == VALID_API_KEY
assert client.wallet_version == "V5R1" assert client.wallet_version == "V5R1"
assert client.api_provider == "tonapi"
# API provider tests
def test_api_provider_default_is_tonapi() -> None:
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
assert client.api_provider == "tonapi"
def test_api_provider_toncenter() -> None:
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="toncenter")
assert client.api_provider == "toncenter"
def test_api_provider_is_case_insensitive() -> None:
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="TONAPI")
assert client.api_provider == "tonapi"
def test_unsupported_api_provider_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, api_provider="infura")
# Wallet version tests # Wallet version tests
@@ -68,11 +44,6 @@ def test_missing_seed_raises() -> None:
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES) FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
def test_both_seed_and_api_key_missing_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed="", api_key="", cookies=VALID_COOKIES)
def test_whitespace_only_seed_raises() -> None: def test_whitespace_only_seed_raises() -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES) FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
@@ -85,7 +56,7 @@ def test_invalid_mnemonic_length_raises() -> None:
def test_valid_mnemonic_lengths() -> None: def test_valid_mnemonic_lengths() -> None:
for length in sorted(MNEMONIC_WORD_COUNTS_VALID): for length in (12, 18, 24):
seed = " ".join(["abandon"] * (length - 1) + ["about"]) seed = " ".join(["abandon"] * (length - 1) + ["about"])
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES) client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
assert len(client.seed.split()) == length assert len(client.seed.split()) == length
@@ -99,6 +70,11 @@ def test_missing_api_key_raises() -> None:
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES) FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
def test_short_api_key_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
# Cookie validation tests # Cookie validation tests
@@ -134,7 +110,6 @@ def test_repr() -> None:
r = repr(client) r = repr(client)
assert "FragmentClient" in r assert "FragmentClient" in r
assert "V5R1" in r assert "V5R1" in r
assert "tonapi" in r
assert "4 keys" in r assert "4 keys" in r
+17 -64
View File
@@ -1,15 +1,13 @@
"""Exercise transaction signing, balance checks, and retry behavior for GRAM (ex TON) and USDT flows.""" """Unit tests for process_transaction() — balance validation and broadcast retry logic."""
from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from tonutils.exceptions import ProviderResponseError from tonutils.exceptions import ProviderResponseError
from pyfragment import TransactionError, WalletError from pyfragment.types import TransactionError, WalletError
from pyfragment.enums import PaymentMethod from pyfragment.utils.wallet import process_transaction
from pyfragment.services.tonapi.transaction import process_transaction
from tests.shared import VALID_SEED from tests.shared import VALID_SEED
@@ -22,7 +20,7 @@ TRANSACTION_DATA = {
"messages": [ "messages": [
{ {
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a", "address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
"amount": "500000000", # 0.5 GRAM "amount": "500000000", # 0.5 TON
"payload": "", "payload": "",
} }
] ]
@@ -47,14 +45,13 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
@contextmanager @contextmanager
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]: def _patch_wallet(wallet: MagicMock):
mock_ton_ctx = MagicMock()
mock_ton_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
mock_ton_ctx.__aexit__ = AsyncMock(return_value=False)
with ( with (
patch("pyfragment.services.tonapi.transaction._make_ton_client", return_value=mock_ton_ctx), patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.services.tonapi.transaction.WALLET_CLASSES") as mock_classes, patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None) mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
yield yield
@@ -64,8 +61,8 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sufficient_balance_broadcasts() -> None: async def test_sufficient_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 GRAM, above threshold wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
wallet.transfer.assert_called_once() wallet.transfer.assert_called_once()
@@ -73,7 +70,7 @@ async def test_sufficient_balance_broadcasts() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_insufficient_balance_raises() -> None: async def test_insufficient_balance_raises() -> None:
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 GRAM, below threshold wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON
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)
@@ -82,15 +79,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=500_000_000) # exactly transaction amount threshold wallet = _make_wallet(balance_nanotons=556_000_000) # exactly 0.5 + 0.056 TON
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"): with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_one_nanoton_below_minimum_raises() -> None: async def test_one_nanoton_below_minimum_raises() -> None:
wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanogram below transaction amount threshold wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton 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)
@@ -105,12 +102,6 @@ async def test_invalid_payload_raises() -> None:
await process_transaction(_make_client(), {"transaction": {}}) await process_transaction(_make_client(), {"transaction": {}})
@pytest.mark.asyncio
async def test_empty_messages_list_raises() -> None:
with pytest.raises(TransactionError):
await process_transaction(_make_client(), {"transaction": {"messages": []}})
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_balance_check_failed_raises_wallet_error() -> None: async def test_balance_check_failed_raises_wallet_error() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
@@ -125,7 +116,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
async def test_rate_limit_retries_and_succeeds() -> None: async def test_rate_limit_retries_and_succeeds() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")]) wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""): with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
assert wallet.transfer.call_count == 2 assert wallet.transfer.call_count == 2
@@ -136,45 +127,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
err = _provider_error(406, "Duplicate msg_seqno") err = _provider_error(406, "Duplicate msg_seqno")
wallet.transfer = AsyncMock(side_effect=[err, err, err]) wallet.transfer = AsyncMock(side_effect=[err, err, err])
with _patch_wallet(wallet), patch("pyfragment.services.tonapi.transaction.clean_decode", return_value=""): with _patch_wallet(wallet), patch("pyfragment.utils.wallet.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,
)
+23 -224
View File
@@ -1,21 +1,14 @@
"""Cover stars purchase and giveaway flows, including validation and request wiring.""" """Unit tests for Stars methods — purchase_stars and giveaway_stars."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod _purchase_stars_mod = importlib.import_module("pyfragment.methods.purchase_stars")
import pyfragment.domains.purchases.purchase as _purchase_stars_mod _giveaway_stars_mod = importlib.import_module("pyfragment.methods.giveaway_stars")
from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError from pyfragment import FragmentClient
from pyfragment.core.constants import ( from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError
STARS_GIVEAWAY_MAX,
STARS_GIVEAWAY_MIN,
STARS_PURCHASE_MAX,
STARS_PURCHASE_MIN,
STARS_WINNERS_MAX,
STARS_WINNERS_MIN,
)
from pyfragment.enums import PaymentMethod
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Stars purchase validation tests # Stars purchase validation tests
@@ -24,13 +17,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSAC
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None: async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.purchase_stars("@user", amount=STARS_PURCHASE_MIN - 1) await client.purchase_stars("@user", amount=49)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None: async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.purchase_stars("@user", amount=STARS_PURCHASE_MAX + 1) await client.purchase_stars("@user", amount=1_000_001)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -39,27 +32,23 @@ 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:
call_mock = AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updateStarsBuyState
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
)
with ( with (
patch.object(client, "call", call_mock), patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
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)),
): ):
@@ -71,44 +60,6 @@ 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": {}})):
@@ -122,25 +73,25 @@ async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None: async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN - 1, amount=STARS_GIVEAWAY_MIN) await client.giveaway_stars("@channel", winners=0, amount=500)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None: async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MAX + 1, amount=STARS_GIVEAWAY_MIN) await client.giveaway_stars("@channel", winners=6, amount=500)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None: async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MIN - 1) await client.giveaway_stars("@channel", winners=1, amount=499)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None: async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MAX + 1) await client.giveaway_stars("@channel", winners=1, amount=1_000_001)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -155,12 +106,6 @@ 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
@@ -173,8 +118,6 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
AsyncMock( AsyncMock(
side_effect=[ side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}}, {"found": {"recipient": FAKE_RECIPIENT}},
{},
{},
{"req_id": FAKE_REQ_ID}, {"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION, FAKE_TRANSACTION,
] ]
@@ -192,152 +135,8 @@ 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)
+9 -225
View File
@@ -1,15 +1,14 @@
"""Cover premium purchase and giveaway flows, including validation and request wiring.""" """Unit tests for Premium methods — purchase_premium and giveaway_premium."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod _purchase_premium_mod = importlib.import_module("pyfragment.methods.purchase_premium")
import pyfragment.domains.purchases.purchase as _purchase_premium_mod _giveaway_premium_mod = importlib.import_module("pyfragment.methods.giveaway_premium")
from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError from pyfragment import FragmentClient
from pyfragment.core.constants import PREMIUM_MONTHS_VALID, PREMIUM_WINNERS_MAX, PREMIUM_WINNERS_MIN from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError
from pyfragment.enums import PaymentMethod
from pyfragment.exceptions import AlreadySubscribedError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Premium purchase validation tests # Premium purchase validation tests
@@ -24,13 +23,7 @@ async def test_purchase_premium_invalid_months(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_purchase_premium_months_zero(client: FragmentClient) -> None: async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.purchase_premium("@user", months=min(PREMIUM_MONTHS_VALID) - 1) await client.purchase_premium("@user", months=0)
@pytest.mark.asyncio
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
@@ -62,63 +55,6 @@ 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": {}})):
@@ -132,13 +68,13 @@ async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None: async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MIN - 1, months=3) await client.giveaway_premium("@channel", winners=0, months=3)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None: async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MAX + 1, months=3) await client.giveaway_premium("@channel", winners=24_001, months=3)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -153,12 +89,6 @@ 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
@@ -171,8 +101,6 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
AsyncMock( AsyncMock(
side_effect=[ side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}}, {"found": {"recipient": FAKE_RECIPIENT}},
{},
{},
{"req_id": FAKE_REQ_ID}, {"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION, FAKE_TRANSACTION,
] ]
@@ -190,152 +118,8 @@ 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)
+19 -66
View File
@@ -1,40 +1,41 @@
"""Cover GRAM (ex TON) top-up through Telegram Ads, including recipient lookup and transaction building.""" """Unit tests for topup_ton — TON Ads balance top-up."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
import pyfragment.domains.ads.tonup as _topup_gram_mod _topup_ton_mod = importlib.import_module("pyfragment.methods.topup_ton")
from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError from pyfragment import FragmentClient
from pyfragment.core.constants import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Topup GRAM (ex TON) validation tests # Topup TON validation tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_topup_gram_amount_zero(client: FragmentClient) -> None: async def test_topup_ton_amount_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.topup_gram("@user", amount=GRAM_TOPUP_MIN - 1) await client.topup_ton("@user", amount=0)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_topup_gram_amount_too_high(client: FragmentClient) -> None: async def test_topup_ton_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.topup_gram("@user", amount=GRAM_TOPUP_MAX + 1) await client.topup_ton("@user", amount=1_000_000_001)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_topup_gram_float_amount(client: FragmentClient) -> None: async def test_topup_ton_float_amount(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.topup_gram("@user", amount=1.5) # type: ignore[arg-type] await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type]
# Topup GRAM (ex TON) mocked tests # Topup TON mocked tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_topup_gram_success(client: FragmentClient) -> None: async def test_topup_ton_success(client: FragmentClient) -> None:
with ( with (
patch.object( patch.object(
client, client,
@@ -48,10 +49,10 @@ async def test_topup_gram_success(client: FragmentClient) -> None:
] ]
), ),
), ),
patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_topup_gram_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
): ):
result = await client.topup_gram("@user", amount=10) result = await client.topup_ton("@user", amount=10)
assert isinstance(result, AdsTopupResult) assert isinstance(result, AdsTopupResult)
assert result.transaction_id == FAKE_TX_HASH assert result.transaction_id == FAKE_TX_HASH
@@ -60,7 +61,7 @@ async def test_topup_gram_success(client: FragmentClient) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_topup_gram_user_not_found(client: FragmentClient) -> None: async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
with patch.object( with patch.object(
client, client,
"call", "call",
@@ -72,52 +73,4 @@ async def test_topup_gram_user_not_found(client: FragmentClient) -> None:
), ),
): ):
with pytest.raises(UserNotFoundError): with pytest.raises(UserNotFoundError):
await client.topup_gram("@ghost", amount=10) await client.topup_ton("@ghost", amount=10)
# topup_gram — error branches
@pytest.mark.asyncio
async def test_topup_gram_missing_req_id_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import FragmentAPIError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsTopupState
{"found": {"recipient": FAKE_RECIPIENT}},
{"amount": "0.1"}, # initAdsTopupRequest — no req_id
]
),
),
patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(FragmentAPIError):
await client.topup_gram("@user", amount=10)
@pytest.mark.asyncio
async def test_topup_gram_need_verify_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import VerificationError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsTopupState
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
{"need_verify": True}, # getAdsTopupLink
]
),
),
patch.object(_topup_gram_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(VerificationError):
await client.topup_gram("@user", amount=10)
+12 -110
View File
@@ -1,30 +1,29 @@
"""Verify wallet inspection returns friendly GRAM (ex TON) and USDT balances from Tonapi.""" """Unit tests for get_wallet() — wallet address and TON balance lookup."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from pyfragment import FragmentClient, WalletInfo from pyfragment import FragmentClient, WalletInfo
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOGRAM from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
# Wallet mocked tests (GRAM and USDT balances are returned separately) # Wallet mocked tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None: async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
mock_wallet = MagicMock() mock_wallet = MagicMock()
mock_wallet.refresh = AsyncMock() mock_wallet.refresh = AsyncMock()
mock_wallet.balance = FAKE_BALANCE_NANOGRAM mock_wallet.balance = FAKE_BALANCE_NANOTON
mock_wallet.state = MagicMock(value="active") mock_wallet.state = MagicMock(value="active")
mock_wallet.address.to_str.return_value = FAKE_ADDRESS mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with ( with (
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi, patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes, patch("pyfragment.utils.wallet.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) # _make_ton_client returns context manager mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None) mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
result = await client.get_wallet() result = await client.get_wallet()
@@ -32,8 +31,7 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
assert isinstance(result, WalletInfo) assert isinstance(result, WalletInfo)
assert result.address == FAKE_ADDRESS assert result.address == FAKE_ADDRESS
assert result.state == "active" assert result.state == "active"
assert result.gram_balance == round(FAKE_BALANCE_NANOGRAM / 1_000_000_000, 4) assert result.balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
assert result.usdt_balance == 12.3456
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -45,110 +43,14 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
mock_wallet.address.to_str.return_value = FAKE_ADDRESS mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with ( with (
patch("pyfragment.services.tonapi.account._make_ton_client") as mock_tonapi, patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.services.tonapi.account.WALLET_CLASSES") as mock_classes, patch("pyfragment.utils.wallet.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) # _make_ton_client returns context manager mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None) mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
result = await client.get_wallet() result = await client.get_wallet()
assert result.gram_balance == 0.0 assert result.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 -28
View File
@@ -1,12 +1,10 @@
"""Check raw Fragment API calls and transport error handling.""" """Unit tests for FragmentClient.call() — raw Fragment API access."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from curl_cffi.requests import AsyncSession, Response
from pyfragment import FragmentClient, FragmentPageError from pyfragment import FragmentClient
from pyfragment.core.transport 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
@@ -15,8 +13,8 @@ from tests.shared import FAKE_HASH, FAKE_RESPONSE
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_call_returns_api_response(client: FragmentClient) -> None: async def test_call_returns_api_response(client: FragmentClient) -> None:
with ( with (
patch("pyfragment.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
): ):
result = await client.call("anyMethod", {"key": "value"}) result = await client.call("anyMethod", {"key": "value"})
@@ -25,9 +23,10 @@ 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.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
): ):
result = await client.call("anyMethod") result = await client.call("anyMethod")
@@ -36,11 +35,12 @@ 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.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.fragment_request", mock_request), patch("pyfragment.client.fragment_request", mock_request),
): ):
await client.call("anyMethod") await client.call("anyMethod")
@@ -50,28 +50,14 @@ 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.domains.base.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.domains.base.fragment_request", mock_request), patch("pyfragment.client.fragment_request", mock_request),
): ):
await client.call("anyMethod", {"key": "value", "num": 7}) await client.call("anyMethod", {"key": "value", "num": 7})
_, _, _, sent_data = mock_request.call_args.args _, _, _, sent_data = mock_request.call_args.args
assert sent_data == {"method": "anyMethod", "key": "value", "num": 7} assert sent_data == {"method": "anyMethod", "key": "value", "num": 7}
# fragment_request HTTP status tests
@pytest.mark.asyncio
async def test_fragment_request_non_200_raises() -> None:
response = MagicMock(spec=Response)
response.status_code = 429
session = AsyncMock(spec=AsyncSession)
session.post = AsyncMock(return_value=response)
with pytest.raises(FragmentPageError, match="429"):
await fragment_request(session, FAKE_HASH, {}, {"method": "anyMethod"})
+7 -52
View File
@@ -1,12 +1,13 @@
"""Cover Telegram Ads recharge flow, including request preparation and KYC handling.""" """Unit tests for recharge_ads — self-service Telegram Ads recharge."""
import importlib
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
import pyfragment.domains.ads.recharge as _recharge_ads_mod _recharge_ads_mod = importlib.import_module("pyfragment.methods.recharge_ads")
from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient from pyfragment import FragmentClient
from pyfragment.core.constants import GRAM_TOPUP_MAX, GRAM_TOPUP_MIN from pyfragment.types import AdsRechargeResult, ConfigurationError
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# recharge_ads validation tests # recharge_ads validation tests
@@ -15,13 +16,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANS
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None: async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MIN - 1) await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None: async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=GRAM_TOPUP_MAX + 1) await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -55,49 +56,3 @@ async def test_recharge_ads_success(client: FragmentClient) -> None:
assert isinstance(result, AdsRechargeResult) assert isinstance(result, AdsRechargeResult)
assert result.transaction_id == FAKE_TX_HASH assert result.transaction_id == FAKE_TX_HASH
assert result.amount == 10 assert result.amount == 10
# recharge_ads — error branches
@pytest.mark.asyncio
async def test_recharge_ads_missing_req_id_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import FragmentAPIError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsState
{"amount": "0.1"}, # initAdsRechargeRequest — no req_id
]
),
),
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(FragmentAPIError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
@pytest.mark.asyncio
async def test_recharge_ads_need_verify_raises(client: FragmentClient) -> None:
from pyfragment.exceptions import VerificationError
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsState
{"req_id": FAKE_REQ_ID},
{"need_verify": True}, # getAdsRechargeLink
]
),
),
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
):
with pytest.raises(VerificationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
+3 -2
View File
@@ -1,10 +1,11 @@
"""Verify username search parsing and query forwarding.""" """Unit tests for search_usernames — Fragment marketplace username search."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from pyfragment import FragmentClient, UsernamesResult from pyfragment import FragmentClient
from pyfragment.types import UsernamesResult
FAKE_HTML = """ FAKE_HTML = """
<tr class="tm-row-selectable"> <tr class="tm-row-selectable">
+3 -2
View File
@@ -1,10 +1,11 @@
"""Verify anonymous number search parsing and query forwarding.""" """Unit tests for search_numbers — Fragment marketplace number search."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from pyfragment import FragmentClient, NumbersResult from pyfragment import FragmentClient
from pyfragment.types import NumbersResult
FAKE_HTML = """ FAKE_HTML = """
<tr class="tm-row-selectable"> <tr class="tm-row-selectable">

Some files were not shown because too many files have changed in this diff Show More