20 Commits

Author SHA1 Message Date
Bohdan 👾 e1478ec8b0 Merge pull request #24 from bohd4nx/dev
Bump version to 2026.2.3 and refactor wallet utilities
2026-05-12 02:03:12 +03:00
bohd4nx 363e0719ab Bump version to 2026.2.3 and refactor wallet utilities
- Updated version in pyproject.toml to 2026.2.3.
- Refactored wallet utilities:
  - Moved `clean_decode` and `process_transaction` to `transaction.py`.
  - Created `balance.py` for balance-related functions.
  - Created `info.py` for wallet information retrieval.
  - Created `transfer.py` for sending TON and USDT transfers.
- Updated tests to reflect new module structure and imports.
- Added new API utility functions for handling Fragment API requests.
2026-05-12 02:01:15 +03:00
Bohdan 👾 35ab0e337d Merge pull request #22 from bohd4nx/dev
Release 2026.2.2: payment_method cleanup, wallet balance behavior updates, docs/tests sync
2026-05-11 13:26:04 +03:00
bohd4nx b67415bcc2 chore: remove redundant reference to examples directory in README 2026-05-11 13:21:18 +03:00
bohd4nx 5df95bea29 chore: update version to 2026.2.2 and modify changelog for release 2026-05-11 13:19:56 +03:00
bohd4nx 7412448f9c feat: enhance payment processing with minimum balance checks and update documentation 2026-05-11 13:11:28 +03:00
bohd4nx 6dd9dcb5d4 feat: update wallet handling to support separate TON and USDT balances
- Refactor `get_wallet()` to return `ton_balance` and `usdt_balance` in `WalletInfo`.
- Update transaction processing to validate balances for both TON and USDT payment methods.
- Introduce `parse_required_payment_amount` utility to extract payment amounts from responses.
- Modify tests to cover new balance checks and payment method handling.
- Upgrade GitHub Actions artifact upload action to v7.
- Enhance documentation and examples to reflect changes in wallet balance handling.
2026-05-11 13:02:00 +03:00
bohd4nx 311222d478 feat: add payment method options for purchases and giveaways, enhance documentation and examples 2026-05-11 12:12:07 +03:00
bohd4nx 72918a6dd6 feat: add payment method support for premium and stars purchases, including validation and tests 2026-05-11 11:57:52 +03:00
Bohdan 👾 eec59f7e12 Merge pull request #20 from bohd4nx/dev
Dev
2026-05-03 19:21:35 +03:00
bohd4nx c1ae5f0f28 fix: update download-artifact action version to v8 for consistency 2026-05-03 19:20:30 +03:00
bohd4nx 4873d6d6dd fix: update PyPI publish action to use the latest release version
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 19:19:54 +03:00
Bohdan 👾 1f6085acdc Merge pull request #18 from bohd4nx/dev
Dev
2026-05-03 19:16:19 +03:00
bohd4nx d5dfed5f1a fix: update CI and publish workflows to use consistent action versions and improve error handling in API requests
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 19:10:11 +03:00
bohd4nx 7923dff8b2 fix: remove unnecessary 'method' key from API request data and improve error handling for expired cookies and non-200 HTTP responses 2026-04-21 20:06:11 +03:00
bohd4nx 8c7423a6ab fix: update Python version in CI workflow and refine project metadata for clarity 2026-04-18 21:11:14 +03:00
bohd4nx 8f3e9f4bd8 chore: update action versions in CI workflow and adjust linter ignores for compatibility 2026-04-18 21:05:12 +03:00
bohd4nx fca60135a6 refactor: update type hints for better clarity and consistency across the codebase 2026-04-18 21:02:53 +03:00
bohd4nx d66602b646 fix: replace Miniconda setup with Python setup in CI workflow for consistency 2026-04-14 02:03:22 +03:00
bohd4nx 333b6f45ce fix: update Miniconda setup action version and adjust activation settings 2026-04-14 02:01:16 +03:00
54 changed files with 1521 additions and 661 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: input - type: markdown
attributes: attributes:
label: Python version value: |
description: Run `python --version` inside your virtualenv ## Context
placeholder: e.g. 3.12.3 Please provide as much detail as possible to help us reproduce and fix the issue.
validations:
required: true
- type: input - type: input
attributes: attributes:
label: pyfragment version label: Operating system
description: Run `pip show pyfragment` inside your virtualenv placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
placeholder: e.g. 2026.1.0 validations:
validations: required: true
required: true
- type: textarea - type: input
attributes: attributes:
label: Expected behavior label: Python version
description: Describe what you expected to happen. description: Run `python --version` inside your virtualenv
placeholder: e.g. Stars should be purchased and StarsResult returned. placeholder: e.g. 3.12.3
validations: validations:
required: true required: true
- type: textarea - type: input
attributes: attributes:
label: Current behavior label: pyfragment version
description: Describe what is actually happening. description: Run `pip show pyfragment` inside your virtualenv
placeholder: e.g. ParseError is raised with status 400. placeholder: e.g. 2026.1.0
validations: validations:
required: true required: true
- type: textarea - type: textarea
attributes: attributes:
label: Steps to reproduce label: Expected behavior
description: Minimal steps that reproduce the issue. description: Describe what you expected to happen.
placeholder: | placeholder: e.g. Stars should be purchased and StarsResult returned.
1. Create FragmentClient with valid credentials validations:
2. Call purchase_stars("@username", amount=100) required: true
3. See error
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
label: Code example label: Current behavior
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable. description: Describe what is actually happening.
placeholder: | placeholder: e.g. ParseError is raised with status 400.
import asyncio validations:
from pyfragment import FragmentClient required: true
async def main(): - type: textarea
client = FragmentClient(...) attributes:
result = await client.purchase_stars("@username", amount=100) label: Steps to reproduce
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
asyncio.run(main()) - type: textarea
render: python attributes:
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
- type: textarea async def main():
attributes: client = FragmentClient(...)
label: Traceback / logs result = await client.purchase_stars("@username", amount=100)
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 asyncio.run(main())
attributes: render: python
label: Additional information
description: Anything else that might help us diagnose the problem. - type: textarea
placeholder: e.g. Only happens with V5R1 wallet version. attributes:
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"
+17 -23
View File
@@ -2,9 +2,9 @@ name: CI
on: on:
push: push:
branches: ["**"] branches: [ "**" ]
pull_request: pull_request:
branches: ["**"] branches: [ "**" ]
jobs: jobs:
lint: lint:
@@ -14,15 +14,15 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0 - uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.10"
- run: pip install ".[dev]" - run: pip install ".[dev]"
- run: ruff check . && black --check . --target-version py312 && mypy pyfragment - run: ruff check . && ruff format --check . && mypy pyfragment
test: test:
name: Tests (Python ${{ matrix.python-version }}) name: Tests (Python ${{ matrix.python-version }})
@@ -33,23 +33,17 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.10", "3.11", "3.12"] # 3.13, 3.14 are not supported by some dependencies yet python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6
- name: Set up conda (Miniconda) - uses: actions/setup-python@v6
uses: conda-incubator/setup-miniconda@v3 with:
with: python-version: ${{ matrix.python-version }}
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
shell: bash -el {0} run: pip install ".[dev]"
run: pip install ".[dev]"
- name: Run tests - name: Run tests
shell: bash -el {0} run: pytest
run: pytest
+57 -57
View File
@@ -2,9 +2,9 @@ name: Publish
on: on:
workflow_run: workflow_run:
workflows: ["CI"] workflows: [ "CI" ]
types: [completed] types: [ completed ]
branches: [master] branches: [ master ]
jobs: jobs:
version-check: version-check:
@@ -18,24 +18,24 @@ jobs:
is-new: ${{ steps.tag.outputs.is-new }} is-new: ${{ steps.tag.outputs.is-new }}
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6
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
@@ -46,24 +46,24 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0 - uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- uses: astral-sh/setup-uv@v7.6.0 - uses: astral-sh/setup-uv@v8.1.0
- run: uv build - run: uv build
- uses: actions/upload-artifact@v7 - 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: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -74,16 +74,16 @@ jobs:
id-token: write id-token: write
steps: steps:
- uses: actions/download-artifact@v8.0.1 - uses: actions/download-artifact@v8
with: with:
name: dist name: dist
path: dist path: dist
- uses: pypa/gh-action-pypi-publish@v1.13.0 - uses: pypa/gh-action-pypi-publish@release/v1
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: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -91,27 +91,27 @@ jobs:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/download-artifact@v8.0.1 - uses: actions/download-artifact@v8
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@v2.6.1 - uses: softprops/action-gh-release@v3
with: with:
tag_name: v${{ needs.version-check.outputs.version }} tag_name: v${{ needs.version-check.outputs.version }}
name: v${{ needs.version-check.outputs.version }} name: v${{ needs.version-check.outputs.version }}
files: dist/* files: dist/*
body: ${{ steps.changelog.outputs.body }} body: ${{ steps.changelog.outputs.body }}
make_latest: true make_latest: true
+76
View File
@@ -7,6 +7,68 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
--- ---
## [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
@@ -33,37 +95,44 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
### 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
@@ -76,15 +145,18 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
## [2026.0.2] — 2026-03-20 ## [2026.0.2] — 2026-03-20
### Added ### Added
- `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request - `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request
### Changed ### Changed
- Cookie validation: narrowed type internally so no `# type: ignore` is needed in `FragmentClient.__init__` - Cookie validation: narrowed type internally so no `# type: ignore` is needed in `FragmentClient.__init__`
- `WALLET_CLASSES` typed as `dict[str, Any]` so mypy resolves `from_mnemonic` correctly - `WALLET_CLASSES` typed as `dict[str, Any]` so mypy resolves `from_mnemonic` correctly
- All four `examples/` files updated to `async with FragmentClient`, f-strings, and aligned error messages - All four `examples/` files updated to `async with FragmentClient`, f-strings, and aligned error messages
- README usage section rewritten with a single comprehensive `async with` example - README usage section rewritten with a single comprehensive `async with` example
### Fixed ### Fixed
- mypy: missing return path in `process_transaction` after retry loop - mypy: missing return path in `process_transaction` after retry loop
- mypy: `cookies` union-attr error in `FragmentClient.__init__` - mypy: `cookies` union-attr error in `FragmentClient.__init__`
@@ -93,6 +165,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
## [2026.0.1] — 2026-03-16 ## [2026.0.1] — 2026-03-16
### Added ### Added
- Initial stable release of `pyfragment` - Initial stable release of `pyfragment`
- `FragmentClient` — async client for the Fragment.com API with context manager support (`async with`) - `FragmentClient` — async client for the Fragment.com API with context manager support (`async with`)
- `purchase_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months) - `purchase_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months)
@@ -104,6 +177,9 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
- `py.typed` marker — full PEP 561 typing support for type-checkers - `py.typed` marker — full PEP 561 typing support for type-checkers
- `__repr__` on all result types for readable debug output - `__repr__` on all result types for readable debug output
[2026.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
+22 -43
View File
@@ -4,15 +4,14 @@
<h1 style="margin-top: 24px;">Fragment API</h1> <h1 style="margin-top: 24px;">Fragment API</h1>
<p style="font-size: 18px; margin-bottom: 24px;"> <p style="font-size: 18px; margin-bottom: 24px;">
<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> <b>Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings.</b>
</p> </p>
[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) [![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/) [![PyPI Downloads](https://static.pepy.tech/personalized-badge/pyfragment?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/pyfragment)
[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) [![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) [![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)
[![Stars](https://img.shields.io/github/stars/bohd4nx/pyfragment?style=flat&color=yellow)](https://github.com/bohd4nx/pyfragment/stargazers) [![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=green)](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE)
[![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)
[Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5) [Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
@@ -75,22 +74,13 @@ Refresh when you get authentication errors.
```python ```python
import asyncio import asyncio
from pyfragment import ( from pyfragment import FragmentClient
FragmentClient,
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", # 24-word TON wallet mnemonic seed="word1 word2 ... word24",
api_key="YOUR_TONAPI_KEY", # from tonconsole.com api_key="YOUR_TONAPI_KEY",
cookies={ cookies={
"stel_ssid": "...", "stel_ssid": "...",
"stel_dt": "...", "stel_dt": "...",
@@ -98,40 +88,29 @@ async def main() -> None:
"stel_ton_token": "...", "stel_ton_token": "...",
}, },
) as client: ) as client:
try: wallet = await client.get_wallet()
# Purchase 6 months of Telegram Premium print(f"Wallet: {wallet.address} | TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
result = await client.purchase_premium("@username", months=6)
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
# Purchase 500 Stars recipient = "https://t.me/username" # also supports: @username, username
result = await client.purchase_stars("@username", amount=500)
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
# Top up 10 TON to Telegram balance stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton")
# wallet must hold at least amount + ~0.056 TON for gas print(f"Stars sent: {stars.amount} to {stars.username} | tx: {stars.transaction_id}")
result = await client.topup_ton("@username", amount=10)
print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}")
except UserNotFoundError: premium = await client.purchase_premium(recipient, months=6, payment_method="ton")
print(f"User was not found on fragment.com — check the username and try again.") print(f"Premium sent: {premium.amount} months to {premium.username} | tx: {premium.transaction_id}")
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())
``` ```
Full runnable examples:
- https://github.com/bohd4nx/pyfragment/tree/master/examples
Payload debug/decode helper (thanks):
- https://ton-cell-abi-viewer.vercel.app/
--- ---
<div align="center"> <div align="center">
+3 -2
View File
@@ -1,5 +1,5 @@
""" """
Example: fetch wallet address, state, and balance. Example: fetch wallet address, state, and separate TON/USDT balances.
Cookies can be passed as a dict or as a JSON string. Cookies can be passed as a dict or as a JSON string.
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets. wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
@@ -35,7 +35,8 @@ async def main() -> None:
wallet = await client.get_wallet() wallet = await client.get_wallet()
print(f"Address: {wallet.address}") print(f"Address: {wallet.address}")
print(f"State: {wallet.state}") print(f"State: {wallet.state}")
print(f"Balance: {wallet.balance} TON") print(f"Balance: {wallet.ton_balance} TON")
print(f"Balance: {wallet.usdt_balance} USDT")
if __name__ == "__main__": if __name__ == "__main__":
+1 -1
View File
@@ -2,7 +2,7 @@
Example: recharge your own Telegram Ads account with TON. Example: recharge your own Telegram Ads account with TON.
Amount must be an integer between 1 and 1 000 000 000 TON. Amount must be an integer between 1 and 1 000 000 000 TON.
Your wallet must hold at least the recharge amount + ~0.056 TON for gas. Your wallet must satisfy the current minimum TON threshold and transaction cost.
""" """
import asyncio import asyncio
+10 -2
View File
@@ -3,6 +3,8 @@ 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.
payment_method can be "ton" or "usdt_ton".
Channel can be "@channel", "channel", or "https://t.me/channel".
""" """
import asyncio import asyncio
@@ -24,15 +26,21 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN", "stel_ton_token": "YOUR_STEL_TON_TOKEN",
} }
CHANNEL = "@channel" CHANNEL = "https://t.me/channel"
WINNERS = 10 # 124 000 WINNERS = 10 # 124 000
MONTHS = 3 # 3, 6 or 12 MONTHS = 3 # 3, 6 or 12
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
async def main() -> None: async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
try: try:
result = await client.giveaway_premium(CHANNEL, winners=WINNERS, months=MONTHS) result = await client.giveaway_premium(
CHANNEL,
winners=WINNERS,
months=MONTHS,
payment_method=PAYMENT_METHOD,
)
except UserNotFoundError: except UserNotFoundError:
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
return return
+10 -2
View File
@@ -3,6 +3,8 @@ Example: run a Telegram Stars giveaway for a channel.
winners must be an integer between 1 and 5. winners must be an integer between 1 and 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.
payment_method can be "ton" or "usdt_ton".
Channel can be "@channel", "channel", or "https://t.me/channel".
""" """
import asyncio import asyncio
@@ -24,15 +26,21 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN", "stel_ton_token": "YOUR_STEL_TON_TOKEN",
} }
CHANNEL = "@channel" CHANNEL = "https://t.me/channel"
WINNERS = 3 # 15 WINNERS = 3 # 15
AMOUNT = 1000 # 5001 000 000 stars per winner AMOUNT = 1000 # 5001 000 000 stars per winner
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
async def main() -> None: async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
try: try:
result = await client.giveaway_stars(CHANNEL, winners=WINNERS, amount=AMOUNT) result = await client.giveaway_stars(
CHANNEL,
winners=WINNERS,
amount=AMOUNT,
payment_method=PAYMENT_METHOD,
)
except UserNotFoundError: except UserNotFoundError:
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
return return
+10 -2
View File
@@ -3,6 +3,8 @@ 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.
payment_method can be "ton" or "usdt_ton".
Username can be "@username", "username", or "https://t.me/username".
""" """
import asyncio import asyncio
@@ -24,14 +26,20 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN", "stel_ton_token": "YOUR_STEL_TON_TOKEN",
} }
USERNAME = "@username" USERNAME = "https://t.me/username"
MONTHS = 3 # 3, 6 or 12 MONTHS = 3 # 3, 6 or 12
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
async def main() -> None: async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
try: try:
result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True) result = await client.purchase_premium(
USERNAME,
months=MONTHS,
show_sender=True,
payment_method=PAYMENT_METHOD,
)
except UserNotFoundError: except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return return
+10 -2
View File
@@ -3,6 +3,8 @@ Example: purchase Telegram Stars for a user.
Amount must be an integer between 50 and 1 000 000. Amount must be an integer between 50 and 1 000 000.
Set show_sender=False to send anonymously. Set show_sender=False to send anonymously.
payment_method can be "ton" or "usdt_ton".
Username can be "@username", "username", or "https://t.me/username".
""" """
import asyncio import asyncio
@@ -24,14 +26,20 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN", "stel_ton_token": "YOUR_STEL_TON_TOKEN",
} }
USERNAME = "@username" USERNAME = "https://t.me/username"
AMOUNT = 500 # 501 000 000 stars AMOUNT = 500 # 501 000 000 stars
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
async def main() -> None: async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
try: try:
result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True) result = await client.purchase_stars(
USERNAME,
amount=AMOUNT,
show_sender=True,
payment_method=PAYMENT_METHOD,
)
except UserNotFoundError: except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return return
+1 -1
View File
@@ -4,7 +4,7 @@ Example: top up TON to a recipient's Telegram balance.
For adding 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 TON. Amount must be an integer between 1 and 1 000 000 000 TON.
Your wallet must hold at least the top-up amount + ~0.056 TON for gas. Your wallet must satisfy the current minimum TON threshold and transaction cost.
""" """
import asyncio import asyncio
+23 -15
View File
@@ -15,6 +15,7 @@ from pyfragment.types import (
CookieError, CookieError,
CookieResult, CookieResult,
FragmentAPIError, FragmentAPIError,
# exceptions
FragmentError, FragmentError,
FragmentPageError, FragmentPageError,
GiftsResult, GiftsResult,
@@ -22,9 +23,12 @@ from pyfragment.types import (
NumbersResult, NumbersResult,
OperationError, OperationError,
ParseError, ParseError,
# literal types
PaymentMethod,
PremiumGiveawayResult, PremiumGiveawayResult,
PremiumResult, PremiumResult,
StarsGiveawayResult, StarsGiveawayResult,
# results
StarsResult, StarsResult,
TerminateSessionsResult, TerminateSessionsResult,
TransactionError, TransactionError,
@@ -41,31 +45,35 @@ __version__: str = version("pyfragment")
__all__ = [ __all__ = [
"__version__", "__version__",
"FragmentClient", "FragmentClient",
"AdsRechargeResult", # results
"StarsResult",
"StarsGiveawayResult",
"PremiumResult",
"PremiumGiveawayResult",
"WalletInfo",
"AdsTopupResult", "AdsTopupResult",
"AdsRechargeResult",
"CookieResult",
"GiftsResult", "GiftsResult",
"LoginCodeResult", "LoginCodeResult",
"NumbersResult", "NumbersResult",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult", "TerminateSessionsResult",
"UsernamesResult", "UsernamesResult",
"WalletInfo", # exceptions
"ClientError",
"ConfigurationError",
"CookieError",
"CookieResult",
"FragmentAPIError",
"FragmentError", "FragmentError",
"FragmentAPIError",
"FragmentPageError", "FragmentPageError",
"ConfigurationError",
"UserNotFoundError",
"WalletError",
"VerificationError",
"TransactionError",
"AnonymousNumberError", "AnonymousNumberError",
"ClientError",
"CookieError",
"OperationError", "OperationError",
"ParseError", "ParseError",
"TransactionError",
"UnexpectedError", "UnexpectedError",
"UserNotFoundError", # literal types
"VerificationError", "PaymentMethod",
"WalletError",
] ]
+56 -32
View File
@@ -1,20 +1,24 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any, cast from typing import Any, cast, get_args
import httpx import httpx
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes from pyfragment.methods import (
from pyfragment.methods.giveaway_premium import giveaway_premium get_login_code,
from pyfragment.methods.giveaway_stars import giveaway_stars giveaway_premium,
from pyfragment.methods.purchase_premium import purchase_premium giveaway_stars,
from pyfragment.methods.purchase_stars import purchase_stars purchase_premium,
from pyfragment.methods.recharge_ads import recharge_ads purchase_stars,
from pyfragment.methods.search_gifts import search_gifts recharge_ads,
from pyfragment.methods.search_numbers import search_numbers search_gifts,
from pyfragment.methods.search_usernames import search_usernames search_numbers,
from pyfragment.methods.topup_ton import topup_ton search_usernames,
terminate_sessions,
toggle_login_codes,
topup_ton,
)
from pyfragment.types import ( from pyfragment.types import (
AdsRechargeResult, AdsRechargeResult,
AdsTopupResult, AdsTopupResult,
@@ -32,13 +36,14 @@ from pyfragment.types import (
WalletInfo, WalletInfo,
) )
from pyfragment.types.constants import ( from pyfragment.types.constants import (
BASE_HEADERS,
DEFAULT_TIMEOUT, DEFAULT_TIMEOUT,
FRAGMENT_BASE_URL, FRAGMENT_BASE_URL,
REQUIRED_COOKIE_KEYS, REQUIRED_COOKIE_KEYS,
SUPPORTED_WALLET_VERSIONS, PaymentMethod,
WalletVersion, WalletVersion,
) )
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers from pyfragment.utils.api import fragment_request, get_fragment_hash
from pyfragment.utils.wallet import get_wallet_info from pyfragment.utils.wallet import get_wallet_info
@@ -77,7 +82,7 @@ class FragmentClient:
self, self,
seed: str, seed: str,
api_key: str, api_key: str,
cookies: dict | str, cookies: dict[str, Any] | str,
wallet_version: str = "V5R1", wallet_version: str = "V5R1",
timeout: float = DEFAULT_TIMEOUT, timeout: float = DEFAULT_TIMEOUT,
) -> None: ) -> None:
@@ -98,25 +103,25 @@ class FragmentClient:
except Exception as exc: except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from 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()] missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict[str, Any], cookies).get(k, "")).strip()]
if missing_keys: if missing_keys:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys))) raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
version = wallet_version.strip().upper() version = wallet_version.strip().upper()
if version not in SUPPORTED_WALLET_VERSIONS: if version not in get_args(WalletVersion):
raise ConfigurationError( raise ConfigurationError(
ConfigurationError.UNSUPPORTED_VERSION.format( ConfigurationError.UNSUPPORTED_VERSION.format(
version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS)) version=version, supported=", ".join(sorted(get_args(WalletVersion)))
) )
) )
self.seed: str = seed.strip() self.seed: str = seed.strip()
self.api_key: str = api_key.strip() self.api_key: str = api_key.strip()
self.cookies: dict = cast(dict, cookies) self.cookies: dict[str, Any] = cast(dict[str, Any], cookies)
self.wallet_version: WalletVersion = version # type: ignore[assignment] self.wallet_version: WalletVersion = version # type: ignore[assignment]
self.timeout: float = timeout self.timeout: float = timeout
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:
@@ -125,31 +130,45 @@ class FragmentClient:
def __repr__(self) -> str: def __repr__(self) -> str:
return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)" return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)"
async def purchase_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult: async def purchase_premium(
self,
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
) -> PremiumResult:
"""Gift Telegram Premium to a user. """Gift Telegram Premium to a user.
Args: Args:
username: Recipient's Telegram username (with or without ``@``). username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
months: Duration — ``3``, ``6``, or ``12``. months: Duration — ``3``, ``6``, or ``12``.
show_sender: Show your name as the sender. Defaults to ``True``. show_sender: Show your name as the sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns: Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
""" """
return await purchase_premium(self, username, months, show_sender) return await purchase_premium(self, username, months, show_sender, payment_method)
async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult: async def purchase_stars(
self,
username: str,
amount: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
) -> StarsResult:
"""Send Telegram Stars to a user. """Send Telegram Stars to a user.
Args: Args:
username: Recipient's Telegram username (with or without ``@``). username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
amount: Number of stars — integer from ``50`` to ``1 000 000``. amount: Number of stars — integer from ``50`` to ``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 — ``"ton"`` (default) or ``"usdt_ton"``.
Returns: Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
""" """
return await purchase_stars(self, username, amount, show_sender) return await purchase_stars(self, username, amount, show_sender, payment_method)
async def topup_ton(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 TON to a recipient's Telegram balance. """Top up TON to a recipient's Telegram balance.
@@ -178,11 +197,12 @@ class FragmentClient:
return await recharge_ads(self, 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 balance of the TON wallet. """Return the address, state, and balances of the wallet.
Returns: Returns:
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state`` :class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON. (``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
``ton_balance`` in TON, and ``usdt_balance`` in USDT.
""" """
return await get_wallet_info(self) return await get_wallet_info(self)
@@ -191,38 +211,42 @@ class FragmentClient:
channel: str, channel: str,
winners: int, winners: int,
amount: int, amount: int,
payment_method: PaymentMethod = "ton",
) -> StarsGiveawayResult: ) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel. """Run a Telegram Stars giveaway for a channel.
Args: Args:
channel: Channel username (with or without ``@``). channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``5``. winners: Number of winners — integer from ``1`` to ``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 — ``"ton"`` (default) or ``"usdt_ton"``.
Returns: Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``. ``winners``, and ``amount``.
""" """
return await giveaway_stars(self, channel, winners, amount) return await giveaway_stars(self, channel, winners, amount, payment_method)
async def giveaway_premium( async def giveaway_premium(
self, self,
channel: str, channel: str,
winners: int, winners: int,
months: int = 3, months: int = 3,
payment_method: PaymentMethod = "ton",
) -> PremiumGiveawayResult: ) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel. """Run a Telegram Premium giveaway for a channel.
Args: Args:
channel: Channel username (with or without ``@``). channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — positive integer. winners: Number of winners — 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 — ``"ton"`` (default) or ``"usdt_ton"``.
Returns: Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``. ``winners``, and ``amount``.
""" """
return await giveaway_premium(self, channel, winners, months) return await giveaway_premium(self, channel, winners, months, payment_method)
async def get_login_code(self, number: str) -> LoginCodeResult: async def get_login_code(self, number: str) -> LoginCodeResult:
"""Fetch the current pending login code for an anonymous number. """Fetch the current pending login code for an anonymous number.
@@ -366,7 +390,7 @@ class FragmentClient:
page_url="https://fragment.com/premium/gift", page_url="https://fragment.com/premium/gift",
) )
""" """
headers = make_headers(page_url) headers = {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session: 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) fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout)
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})}) return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
+3 -3
View File
@@ -22,7 +22,7 @@ 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. """Fetch the current pending login code for an anonymous number.
Args: Args:
@@ -58,7 +58,7 @@ async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResu
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. """Enable or disable login code delivery for an anonymous number.
Args: Args:
@@ -87,7 +87,7 @@ async def toggle_login_codes(client: "FragmentClient", number: str, 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. """Terminate all active Telegram sessions for an anonymous number.
This is a two-step operation: Fragment first returns a confirmation hash, This is a two-step operation: Fragment first returns a confirmation hash,
+27 -7
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, get_args
from pyfragment.types import ( from pyfragment.types import (
ConfigurationError, ConfigurationError,
@@ -12,26 +12,28 @@ from pyfragment.types import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
) )
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, PaymentMethod
from pyfragment.utils import get_account_info, process_transaction from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def giveaway_premium( async def giveaway_premium(
client: "FragmentClient", client: FragmentClient,
channel: str, channel: str,
winners: int, winners: int,
months: int = 3, months: int = 3,
payment_method: PaymentMethod = "ton",
) -> PremiumGiveawayResult: ) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel. """Run a Telegram Premium giveaway for a channel.
Args: Args:
client: Authenticated :class:`FragmentClient` instance. client: Authenticated :class:`FragmentClient` instance.
channel: Channel username (with or without ``@``). channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``24 000``. winners: Number of winners — integer from ``1`` to ``24 000``.
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``. months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns: Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
@@ -47,6 +49,13 @@ async def giveaway_premium(
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM) raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
if months not in (3, 6, 12): if months not in (3, 6, 12):
raise ConfigurationError(ConfigurationError.INVALID_MONTHS) raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
if payment_method not in get_args(PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
)
)
try: try:
result = await client.call( result = await client.call(
@@ -60,9 +69,15 @@ async def giveaway_premium(
result = await client.call( result = await client.call(
"initGiveawayPremiumRequest", "initGiveawayPremiumRequest",
{"recipient": recipient, "quantity": str(winners), "months": str(months)}, {
"recipient": recipient,
"quantity": str(winners),
"months": str(months),
"payment_method": payment_method,
},
page_url=PREMIUM_GIVEAWAY_PAGE, page_url=PREMIUM_GIVEAWAY_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="Premium giveaway")) raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
@@ -81,7 +96,12 @@ async def giveaway_premium(
if transaction.get("need_verify"): if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED) raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction) tx_hash = await process_transaction(
client,
transaction,
payment_method=payment_method,
required_payment_amount=required_payment_amount,
)
return PremiumGiveawayResult( return PremiumGiveawayResult(
transaction_id=tx_hash, transaction_id=tx_hash,
channel=channel, channel=channel,
+27 -7
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, get_args
from pyfragment.types import ( from pyfragment.types import (
ConfigurationError, ConfigurationError,
@@ -12,26 +12,28 @@ from pyfragment.types import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
) )
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE, PaymentMethod
from pyfragment.utils import get_account_info, process_transaction from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def giveaway_stars( async def giveaway_stars(
client: "FragmentClient", client: FragmentClient,
channel: str, channel: str,
winners: int, winners: int,
amount: int, amount: int,
payment_method: PaymentMethod = "ton",
) -> StarsGiveawayResult: ) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel. """Run a Telegram Stars giveaway for a channel.
Args: Args:
client: Authenticated :class:`FragmentClient` instance. client: Authenticated :class:`FragmentClient` instance.
channel: Channel username (with or without ``@``). channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``5``. winners: Number of winners — integer from ``1`` to ``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 — ``"ton"`` (default) or ``"usdt_ton"``.
Returns: Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
@@ -47,6 +49,13 @@ async def giveaway_stars(
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS) raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000): if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER) raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
if payment_method not in get_args(PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
)
)
try: try:
result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE) result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
@@ -56,9 +65,15 @@ async def giveaway_stars(
result = await client.call( result = await client.call(
"initGiveawayStarsRequest", "initGiveawayStarsRequest",
{"recipient": recipient, "quantity": str(winners), "stars": str(amount)}, {
"recipient": recipient,
"quantity": str(winners),
"stars": str(amount),
"payment_method": payment_method,
},
page_url=STARS_GIVEAWAY_PAGE, page_url=STARS_GIVEAWAY_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="Stars giveaway")) raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
@@ -77,7 +92,12 @@ async def giveaway_stars(
if transaction.get("need_verify"): if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED) raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction) tx_hash = await process_transaction(
client,
transaction,
payment_method=payment_method,
required_payment_amount=required_payment_amount,
)
return StarsGiveawayResult( return StarsGiveawayResult(
transaction_id=tx_hash, transaction_id=tx_hash,
channel=channel, channel=channel,
+31 -7
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json import json
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, get_args
from pyfragment.types import ( from pyfragment.types import (
ConfigurationError, ConfigurationError,
@@ -13,21 +13,28 @@ from pyfragment.types import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
) )
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE from pyfragment.types.constants import DEVICE, PREMIUM_PAGE, PaymentMethod
from pyfragment.utils import get_account_info, process_transaction from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult: async def purchase_premium(
client: FragmentClient,
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = "ton",
) -> PremiumResult:
"""Gift Telegram Premium to a user. """Gift Telegram Premium to a user.
Args: Args:
client: Authenticated :class:`FragmentClient` instance. client: Authenticated :class:`FragmentClient` instance.
username: Recipient's Telegram username (with or without ``@``). username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
months: Premium duration — ``3``, ``6``, or ``12``. months: Premium duration — ``3``, ``6``, or ``12``.
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 — ``"ton"`` (default) or ``"usdt_ton"``.
Returns: Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
@@ -40,6 +47,13 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
""" """
if months not in (3, 6, 12): if months not in (3, 6, 12):
raise ConfigurationError(ConfigurationError.INVALID_MONTHS) raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
if payment_method not in get_args(PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
)
)
try: try:
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE) result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
@@ -52,7 +66,12 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
{"mode": "new", "lv": "false", "dh": str(int(time.time()))}, {"mode": "new", "lv": "false", "dh": str(int(time.time()))},
page_url=PREMIUM_PAGE, page_url=PREMIUM_PAGE,
) )
result = await client.call("initGiftPremiumRequest", {"recipient": recipient, "months": months}, page_url=PREMIUM_PAGE) result = await client.call(
"initGiftPremiumRequest",
{"recipient": recipient, "months": months, "payment_method": payment_method},
page_url=PREMIUM_PAGE,
)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id") req_id = result.get("req_id")
if not req_id: if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
@@ -72,7 +91,12 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
if transaction.get("need_verify"): if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED) raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction) tx_hash = await process_transaction(
client,
transaction,
payment_method=payment_method,
required_payment_amount=required_payment_amount,
)
return PremiumResult(transaction_id=tx_hash, username=username, amount=months) return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
except FragmentError: except FragmentError:
+33 -7
View File
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import TYPE_CHECKING import time
from typing import TYPE_CHECKING, get_args
from pyfragment.types import ( from pyfragment.types import (
ConfigurationError, ConfigurationError,
@@ -12,21 +13,24 @@ from pyfragment.types import (
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
) )
from pyfragment.types.constants import DEVICE, STARS_PAGE from pyfragment.types.constants import DEVICE, STARS_PAGE, PaymentMethod
from pyfragment.utils import get_account_info, process_transaction from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING: if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult: async def purchase_stars(
client: FragmentClient, username: str, amount: int, show_sender: bool = True, payment_method: PaymentMethod = "ton"
) -> StarsResult:
"""Send Telegram Stars to a user. """Send Telegram Stars to a user.
Args: Args:
client: Authenticated :class:`FragmentClient` instance. client: Authenticated :class:`FragmentClient` instance.
username: Recipient's Telegram username (with or without ``@``). username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``.
amount: Number of Stars to send — integer from ``50`` to ``1 000 000``. 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``. show_sender: Show your name as the gift sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns: Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
@@ -39,6 +43,13 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s
""" """
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000): if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
if payment_method not in get_args(PaymentMethod):
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(get_args(PaymentMethod))),
)
)
try: try:
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE) result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
@@ -46,7 +57,17 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s
if not recipient: if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
result = await client.call("initBuyStarsRequest", {"recipient": recipient, "quantity": amount}, page_url=STARS_PAGE) await client.call(
"updateStarsBuyState",
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
page_url=STARS_PAGE,
)
result = await client.call(
"initBuyStarsRequest",
{"recipient": recipient, "quantity": amount, "payment_method": payment_method},
page_url=STARS_PAGE,
)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id") req_id = result.get("req_id")
if not req_id: if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
@@ -66,7 +87,12 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s
if transaction.get("need_verify"): if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED) raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction) tx_hash = await process_transaction(
client,
transaction,
payment_method=payment_method,
required_payment_amount=required_payment_amount,
)
return StarsResult(transaction_id=tx_hash, username=username, amount=amount) return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError: except FragmentError:
+1 -1
View File
@@ -18,7 +18,7 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> AdsRechargeResult: async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account. """Add funds to your own Telegram Ads account.
Args: Args:
+2 -2
View File
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
async def search_gifts( async def search_gifts(
client: "FragmentClient", client: FragmentClient,
query: str = "", query: str = "",
collection: str | None = None, collection: str | None = None,
sort: str | None = None, sort: str | None = None,
@@ -45,7 +45,7 @@ async def search_gifts(
FragmentAPIError: If the Fragment API returns an error. FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure. UnexpectedError: For any other unexpected failure.
""" """
data: dict[str, Any] = {"method": "searchAuctions", "type": "gifts", "query": query} data: dict[str, Any] = {"type": "gifts", "query": query}
if collection is not None: if collection is not None:
data["collection"] = collection data["collection"] = collection
if sort is not None: if sort is not None:
+2 -2
View File
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
async def search_numbers( async def search_numbers(
client: "FragmentClient", client: FragmentClient,
query: str = "", query: str = "",
sort: str | None = None, sort: str | None = None,
filter: str | None = None, filter: str | None = None,
@@ -37,7 +37,7 @@ async def search_numbers(
FragmentAPIError: If the Fragment API returns an error. FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure. UnexpectedError: For any other unexpected failure.
""" """
data: dict[str, Any] = {"method": "searchAuctions", "type": "numbers", "query": query} data: dict[str, Any] = {"type": "numbers", "query": query}
if sort is not None: if sort is not None:
data["sort"] = sort data["sort"] = sort
if filter is not None: if filter is not None:
+2 -2
View File
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
async def search_usernames( async def search_usernames(
client: "FragmentClient", client: FragmentClient,
query: str = "", query: str = "",
sort: str | None = None, sort: str | None = None,
filter: str | None = None, filter: str | None = None,
@@ -37,7 +37,7 @@ async def search_usernames(
FragmentAPIError: If the Fragment API returns an error. FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure. UnexpectedError: For any other unexpected failure.
""" """
data: dict[str, Any] = {"method": "searchAuctions", "type": "usernames", "query": query} data: dict[str, Any] = {"type": "usernames", "query": query}
if sort is not None: if sort is not None:
data["sort"] = sort data["sort"] = sort
if filter is not None: if filter is not None:
+1 -1
View File
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
from pyfragment.client import FragmentClient from pyfragment.client import FragmentClient
async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up TON to a recipient's Telegram balance. """Top up TON to a recipient's Telegram balance.
Args: Args:
+7
View File
@@ -1,3 +1,4 @@
from pyfragment.types.constants import PaymentMethod
from pyfragment.types.exceptions import ( from pyfragment.types.exceptions import (
AnonymousNumberError, AnonymousNumberError,
ClientError, ClientError,
@@ -26,6 +27,8 @@ from pyfragment.types.results import (
StarsGiveawayResult, StarsGiveawayResult,
StarsResult, StarsResult,
TerminateSessionsResult, TerminateSessionsResult,
TonTransferResult,
UsdtTransferResult,
UsernamesResult, UsernamesResult,
WalletInfo, WalletInfo,
) )
@@ -59,6 +62,10 @@ __all__ = [
"StarsGiveawayResult", "StarsGiveawayResult",
"StarsResult", "StarsResult",
"TerminateSessionsResult", "TerminateSessionsResult",
"TonTransferResult",
"UsdtTransferResult",
"UsernamesResult", "UsernamesResult",
"WalletInfo", "WalletInfo",
# literal types
"PaymentMethod",
] ]
+15 -6
View File
@@ -1,19 +1,25 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any, Literal, get_args from typing import Any, Literal
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1 from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
# Payment methods
PaymentMethod = Literal["ton", "usdt_ton"]
# Single source of truth for supported wallet versions # Single source of truth for supported wallet versions
WalletVersion = Literal["V4R2", "V5R1"] 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 class map — used to resolve the correct contract from WALLET_VERSION
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
# Minimum wallet balance required to cover TON network gas fees. # Minimum TON balance threshold required for payment flows.
MIN_TON_BALANCE: float = 0.056 MIN_TON_BALANCE: float = 0.33
# USDT (TON) jetton metadata used for payment-method balance checks.
USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
MIN_USDT_BALANCE: float = 0.75
# Default HTTP request timeout in seconds. # Default HTTP request timeout in seconds.
DEFAULT_TIMEOUT: float = 30.0 DEFAULT_TIMEOUT: float = 30.0
@@ -74,12 +80,15 @@ BASE_HEADERS: dict[str, str] = {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8", "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"origin": FRAGMENT_BASE_URL, "origin": FRAGMENT_BASE_URL,
"priority": "u=1, i", "priority": "u=1, i",
"sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua-mobile": "?1",
"sec-ch-ua-platform": '"Android"',
"sec-fetch-dest": "empty", "sec-fetch-dest": "empty",
"sec-fetch-mode": "cors", "sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin", "sec-fetch-site": "same-origin",
"user-agent": ( "user-agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) " "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1" "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
), ),
"x-requested-with": "XMLHttpRequest", "x-requested-with": "XMLHttpRequest",
} }
+10 -9
View File
@@ -16,7 +16,7 @@ class ConfigurationError(ClientError):
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}." UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}." INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
INVALID_API_KEY = ( INVALID_API_KEY = (
"Invalid Tonapi API key: expected at least 68 characters, got {length}. " "Generate a key at https://tonconsole.com." "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_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_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000."
@@ -28,6 +28,7 @@ class ConfigurationError(ClientError):
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5." 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_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000."
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000." INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000."
INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
class CookieError(ClientError): class CookieError(ClientError):
@@ -40,12 +41,13 @@ class CookieError(ClientError):
) )
UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}." UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}."
BROWSER_READ_FAILED = ( BROWSER_READ_FAILED = (
"Failed to read {browser} cookies: {exc}. " "Make sure {browser} is installed and you are logged in to {url}." "Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}."
) )
MISSING_BROWSER_KEYS = ( MISSING_BROWSER_KEYS = (
"Fragment cookies not found in {browser}: {keys}. " "Fragment cookies not found in {browser}: {keys}. "
"Make sure you are logged in to {url} and have connected your TON wallet in {browser}." "Make sure you are logged in to {url} and have connected your 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): class FragmentAPIError(FragmentError):
@@ -74,7 +76,7 @@ class UserNotFoundError(FragmentAPIError):
"""Raised when the target Telegram user is not found on Fragment.""" """Raised when the target Telegram user is not found on Fragment."""
NOT_FOUND = ( NOT_FOUND = (
"Telegram user '{username}' was not found on Fragment. " "Double-check the username and make sure the account exists." "Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
) )
@@ -89,7 +91,7 @@ class TransactionError(FragmentAPIError):
"""Raised when a TON transaction fails to build or broadcast.""" """Raised when a TON transaction fails to build or broadcast."""
INVALID_PAYLOAD = ( INVALID_PAYLOAD = (
"Fragment returned an invalid transaction payload — " "'transaction.messages' is missing or empty in the API response." "Fragment returned an invalid transaction payload — 'transaction.messages' is missing or empty in the API response."
) )
BROADCAST_FAILED = "Transaction broadcast failed: {exc}" BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
BROADCAST_FAILED_SSL = ( BROADCAST_FAILED_SSL = (
@@ -129,11 +131,10 @@ class OperationError(FragmentError):
class WalletError(OperationError): class WalletError(OperationError):
"""Raised for TON wallet issues (connection, balance, account info).""" """Raised for TON wallet issues (connection, balance, account info)."""
LOW_BALANCE = ( LOW_TON_BALANCE = "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required."
"Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required " LOW_USDT_BALANCE = "Insufficient USDT balance: {balance:.4f} USDT available, {required:.4f} USDT required."
"(transaction amount + {gas:.3f} TON gas reserve)." TON_BALANCE_CHECK_FAILED = "Failed to fetch TON balance: {exc}"
) USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}"
BALANCE_CHECK_FAILED = "Failed to fetch wallet balance: {exc}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {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}" WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
+32 -2
View File
@@ -27,10 +27,14 @@ class WalletInfo:
address: str address: str
state: str state: str
balance: float ton_balance: float
usdt_balance: float
def __repr__(self) -> str: def __repr__(self) -> str:
return f"WalletInfo(address='{self.address}', state='{self.state}', balance={self.balance} TON)" return (
f"WalletInfo(address='{self.address}', state='{self.state}', "
f"ton_balance={self.ton_balance} TON, usdt_balance={self.usdt_balance} USDT)"
)
@dataclass @dataclass
@@ -202,6 +206,30 @@ class GiftsResult:
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})" return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})"
@dataclass
class TonTransferResult:
"""Result of a direct TON transfer via :meth:`FragmentClient.send_ton`."""
transaction_id: str
destination: str
amount: int
def __repr__(self) -> str:
return f"TonTransferResult(destination='{self.destination}', amount={self.amount} TON, tx='{self.transaction_id}')"
@dataclass
class UsdtTransferResult:
"""Result of a direct USDT transfer via :meth:`FragmentClient.send_usdt`."""
transaction_id: str
destination: str
amount: int
def __repr__(self) -> str:
return f"UsdtTransferResult(destination='{self.destination}', amount={self.amount} USDT, tx='{self.transaction_id}')"
__all__ = [ __all__ = [
"AdsRechargeResult", "AdsRechargeResult",
"AdsTopupResult", "AdsTopupResult",
@@ -213,6 +241,8 @@ __all__ = [
"StarsGiveawayResult", "StarsGiveawayResult",
"StarsResult", "StarsResult",
"TerminateSessionsResult", "TerminateSessionsResult",
"TonTransferResult",
"UsdtTransferResult",
"UsernamesResult", "UsernamesResult",
"WalletInfo", "WalletInfo",
] ]
+7 -7
View File
@@ -1,14 +1,12 @@
from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser from pyfragment.utils.api import (
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, execute_transaction_request,
fragment_request, fragment_request,
get_fragment_hash, get_fragment_hash,
make_headers,
parse_json_response, parse_json_response,
) )
from pyfragment.utils.wallet import get_account_info, process_transaction from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser
from pyfragment.utils.parser import parse_auction_rows, parse_gift_items, parse_login_code, parse_required_payment_amount
from pyfragment.utils.wallet import clean_decode, get_account_info, process_transaction, send_ton_transfer, send_usdt_transfer
__all__ = [ __all__ = [
"clean_decode", "clean_decode",
@@ -17,11 +15,13 @@ __all__ = [
"parse_auction_rows", "parse_auction_rows",
"parse_gift_items", "parse_gift_items",
"parse_login_code", "parse_login_code",
"parse_required_payment_amount",
"execute_transaction_request", "execute_transaction_request",
"fragment_request", "fragment_request",
"get_account_info", "get_account_info",
"get_fragment_hash", "get_fragment_hash",
"make_headers",
"parse_json_response", "parse_json_response",
"process_transaction", "process_transaction",
"send_ton_transfer",
"send_usdt_transfer",
] ]
@@ -1,16 +1,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import random
import re import re
from typing import Any from typing import Any, cast
import httpx import httpx
from pyfragment.types import FragmentPageError, ParseError, VerificationError from pyfragment.types import FragmentPageError, ParseError, VerificationError
from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL from pyfragment.types.constants import 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( async def get_fragment_hash(
@@ -80,7 +78,7 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any
ParseError: If the response body cannot be decoded as JSON. ParseError: If the response body cannot be decoded as JSON.
""" """
try: try:
return response.json() return cast(dict[str, Any], response.json())
except Exception as exc: except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
@@ -106,12 +104,21 @@ async def fragment_request(
Returns: Returns:
Parsed API response as a dict. Parsed API response as a dict.
""" """
resp = await session.post( for attempt in range(3):
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}", resp = await session.post(
headers=headers, f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
data=data, headers=headers,
) data=data,
return parse_json_response(resp, data.get("method", "request")) )
if resp.status_code == 429 and attempt < 2:
await asyncio.sleep(1 + attempt + random.uniform(0, 0.5))
continue
if resp.status_code != 200:
raise FragmentPageError(
FragmentPageError.BAD_STATUS.format(status=resp.status_code, url=f"{FRAGMENT_BASE_URL}/api")
)
return parse_json_response(resp, data.get("method", "request"))
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
async def execute_transaction_request( async def execute_transaction_request(
+9 -2
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
import rookiepy import rookiepy
from pyfragment.types import CookieError, CookieResult from pyfragment.types import CookieError
from pyfragment.types import CookieResult as CookieResult
from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
@@ -34,7 +36,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported)) raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
try: try:
jar: list[dict] = getattr(rookiepy, key)([FRAGMENT_DOMAIN]) jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
except Exception as exc: except Exception as exc:
raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc
@@ -59,6 +61,11 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
continue continue
break break
if expires_iso:
expires_dt = datetime.fromisoformat(expires_iso)
if expires_dt < datetime.now(timezone.utc):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
return CookieResult( return CookieResult(
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
expires=expires_iso, expires=expires_iso,
-37
View File
@@ -1,37 +0,0 @@
from __future__ import annotations
import base64
from ton_core import Cell
from pyfragment.types import ParseError
def clean_decode(payload: str) -> str:
"""Decode a base64-encoded BOC payload to a plain-text comment string.
Fragment transaction payloads are BOC-serialised TVM cells. This function
base64-decodes the payload, parses the cell, skips the 32-bit op-code
prefix, and reads the snake-encoded UTF-8 comment.
Args:
payload: Base64url-encoded BOC string (padding is added automatically).
Returns:
Decoded comment string, or ``""`` for an empty payload.
Raises:
ParseError: If the payload cannot be decoded or parsed.
"""
s = payload.strip()
if not s:
return ""
s += "=" * (-len(s) % 4)
try:
boc = base64.b64decode(s)
cell = Cell.one_from_boc(boc)
sl = cell.begin_parse()
sl.load_uint(32) # op code — always 0 for text comment
return sl.load_snake_string().strip()
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
@@ -161,3 +161,12 @@ def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
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
return items, next_offset return items, next_offset
def parse_required_payment_amount(init_response: dict[str, Any]) -> float | None:
"""Extract required payment amount from init*Request response."""
raw_amount = init_response.get("amount")
try:
return float(str(raw_amount))
except (TypeError, ValueError):
return None
-148
View File
@@ -1,148 +0,0 @@
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
+14
View File
@@ -0,0 +1,14 @@
from pyfragment.utils.wallet.balance import get_usdt_balance
from pyfragment.utils.wallet.info import get_account_info, get_wallet_info
from pyfragment.utils.wallet.transaction import clean_decode, process_transaction
from pyfragment.utils.wallet.transfer import send_ton_transfer, send_usdt_transfer
__all__ = [
"get_account_info",
"get_usdt_balance",
"get_wallet_info",
"process_transaction",
"clean_decode",
"send_ton_transfer",
"send_usdt_transfer",
]
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
from typing import Any
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
from tonutils.exceptions import ProviderResponseError
from pyfragment.types import WalletError
from pyfragment.types.constants import MIN_TON_BALANCE, MIN_USDT_BALANCE, USDT_TON_MASTER_ADDRESS
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
"""Return wallet USDT balance via tonutils jetton get-methods."""
try:
jetton_wallet_address = await get_wallet_address_get_method(
client=ton,
address=USDT_TON_MASTER_ADDRESS,
owner_address=wallet_address,
)
wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address)
raw_balance = int(wallet_data[0]) if wallet_data else 0
return float(raw_balance) / 1_000_000.0
except ProviderResponseError as exc:
# No jetton wallet deployed yet -> effectively zero USDT balance.
if exc.code == 404:
return 0.0
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
except Exception as exc:
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def check_ton_payment_balance(
balance_ton: float,
amount_ton: float,
required_payment_amount: float | None,
) -> None:
"""Validate balance requirements for TON payment method."""
tx_price_ton = amount_ton
if required_payment_amount is not None and required_payment_amount > 0:
tx_price_ton = max(tx_price_ton, required_payment_amount)
required_ton = max(tx_price_ton, MIN_TON_BALANCE)
if balance_ton < required_ton:
raise WalletError(
WalletError.LOW_TON_BALANCE.format(
balance=balance_ton,
required=required_ton,
)
)
async def check_usdt_payment_balance(
balance_ton: float,
required_payment_amount: float | None,
ton: Any,
wallet_address: str,
) -> None:
"""Validate balance requirements for USDT payment method."""
# USDT payment still needs TON for network fees.
if balance_ton < MIN_TON_BALANCE:
raise WalletError(
WalletError.LOW_TON_BALANCE.format(
balance=balance_ton,
required=MIN_TON_BALANCE,
)
)
usdt_balance = await get_usdt_balance(ton, wallet_address)
required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE
if usdt_balance < required_usdt:
raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt))
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
import base64
from typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient
from pyfragment.types import WalletError, WalletInfo
from pyfragment.types.constants import WALLET_CLASSES
from pyfragment.utils.wallet.balance import get_usdt_balance
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"""Fetch wallet address, public key, and state-init for the Fragment API.
Fragment requires account info to build each transaction payload. The
returned dict is JSON-serialised and passed as the ``account`` field in
``getBuy*Link`` / ``get*Link`` requests.
Args:
client: Authenticated :class:`FragmentClient` instance.
Returns:
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
Raises:
WalletError: If account info cannot be retrieved.
"""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
boc = wallet.state_init.serialize().to_boc()
return {
"address": wallet.address.to_str(False, False),
"publicKey": pub_key.as_hex,
"chain": "-239",
"walletStateInit": base64.b64encode(boc).decode(),
}
except Exception as exc:
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Return the address, state and balance of the TON wallet.
Args:
client: Authenticated :class:`FragmentClient` instance.
Returns:
:class:`WalletInfo` with ``address``, ``state``, ``balance`` in TON,
and ``usdt_balance`` in USDT.
Raises:
WalletError: If the wallet state cannot be fetched.
"""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await wallet.refresh()
wallet_address = wallet.address.to_str(False, False)
usdt_balance = await get_usdt_balance(ton, wallet_address)
return WalletInfo(
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
state=wallet.state.value,
ton_balance=round(wallet.balance / 1_000_000_000, 4),
usdt_balance=round(usdt_balance, 4),
)
except Exception as exc:
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import asyncio
import base64
import random
import ssl
from typing import TYPE_CHECKING, Any
from ton_core import Cell, NetworkGlobalID
from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError
from pyfragment.types import ParseError, TransactionError, WalletError
from pyfragment.types.constants import WALLET_CLASSES, PaymentMethod
from pyfragment.utils.wallet.balance import check_ton_payment_balance, check_usdt_payment_balance
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
def clean_decode(payload: str) -> str | Cell:
"""Decode a base64-encoded BOC payload to a plain-text comment string."""
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 message (e.g. jetton transfer),
# not a plain text comment — return the full cell as-is.
return cell
try:
return sl.load_snake_string().strip()
except UnicodeDecodeError:
return cell
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
async def process_transaction(
client: FragmentClient,
transaction_data: dict[str, Any],
payment_method: PaymentMethod = "ton",
required_payment_amount: float | None = None,
) -> str:
"""Sign and broadcast a Fragment transaction to the TON network.
Validates the payload structure, checks the wallet balance, decodes the
on-chain comment, and calls ``wallet.transfer``.
Args:
client: Authenticated :class:`FragmentClient` instance.
transaction_data: Raw transaction dict from ``execute_transaction_request``.
payment_method: Payment currency — ``"ton"`` or ``"usdt_ton"``.
required_payment_amount: Optional price from init*Request response.
Returns:
Normalised transaction hash string.
Raises:
TransactionError: If the payload is malformed or the broadcast fails.
WalletError: If the wallet balance is too low or cannot be fetched.
"""
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
raise TransactionError(TransactionError.INVALID_PAYLOAD)
message = transaction_data["transaction"]["messages"][0]
amount_ton = int(message["amount"]) / 1_000_000_000
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
# Check balance covers selected payment flow requirements.
try:
await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000
if payment_method == "ton":
wallet.address.to_str(False, False)
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
else:
# USDT is withdrawn from the Fragment-linked wallet (transaction["from"]),
# not from the signing seed wallet. Seed wallet only pays TON gas.
fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
except WalletError:
raise
except Exception as exc:
raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
try:
raw_payload = str(message.get("payload", ""))
payload = clean_decode(raw_payload)
for attempt in range(3):
try:
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
return str(result.normalized_hash)
except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0:
await asyncio.sleep(1 + random.uniform(0, 0.5))
continue
if exc.code == 406 and "seqno" in str(exc).lower():
# Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry
if attempt < 2:
await asyncio.sleep(2 + random.uniform(0, 1))
continue
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
raise
except (WalletError, TransactionError):
raise
except Exception as exc:
cause: BaseException | None = exc
while cause is not None:
if isinstance(cause, ssl.SSLError):
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
cause = cause.__cause__ or cause.__context__
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from ton_core import Address, NetworkGlobalID
from tonutils.clients import ToncenterClient
from tonutils.contracts import JettonTransferBuilder, TONTransferBuilder
from pyfragment.types import TransactionError, WalletError
from pyfragment.types.constants import USDT_TON_MASTER_ADDRESS, WALLET_CLASSES
from pyfragment.types.results import TonTransferResult, UsdtTransferResult
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def send_ton_transfer(
client: FragmentClient,
destination: str,
amount: int,
body: str | None = None,
) -> TonTransferResult:
"""Send a direct TON transfer on-chain using ToncenterClient.
Args:
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used).
destination: Recipient TON address (any format, e.g. ``"UQ..."``).
amount: Amount in nanotons (1 TON = 1 000 000 000 nanotons).
body: Optional on-chain comment attached to the transfer.
Returns:
:class:`TonTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
Raises:
TransactionError: If the transaction fails to broadcast.
"""
try:
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
result = await wallet.transfer_message(
TONTransferBuilder(
destination=Address(destination),
amount=amount,
body=body,
)
)
return TonTransferResult(
transaction_id=str(result.normalized_hash),
destination=destination,
amount=amount,
)
except (TransactionError, WalletError):
raise
except Exception as exc:
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
async def send_usdt_transfer(
client: FragmentClient,
destination: str,
usdt_amount: int,
forward_payload: str | None = None,
ton_for_gas: int = 50_000_000,
) -> UsdtTransferResult:
"""Send a direct USDT (TON jetton) transfer on-chain using ToncenterClient.
Args:
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used).
destination: Recipient TON address (any format, e.g. ``"UQ..."``).
usdt_amount: Amount in USDT base units (6 decimals; 1 USDT = 1 000 000).
forward_payload: Optional comment forwarded to the recipient with the transfer notification.
ton_for_gas: TON attached for gas in nanotons. Defaults to ``50_000_000`` (0.05 TON).
Returns:
:class:`UsdtTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
Raises:
TransactionError: If the transaction fails to broadcast.
"""
try:
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
result = await wallet.transfer_message(
JettonTransferBuilder(
destination=Address(destination),
jetton_amount=usdt_amount,
jetton_master_address=Address(USDT_TON_MASTER_ADDRESS),
forward_payload=forward_payload,
forward_amount=1,
amount=ton_for_gas,
)
)
return UsdtTransferResult(
transaction_id=str(result.normalized_hash),
destination=destination,
amount=usdt_amount,
)
except (TransactionError, WalletError):
raise
except Exception as exc:
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
+45 -26
View File
@@ -4,44 +4,60 @@ build-backend = "hatchling.build"
[project] [project]
name = "pyfragment" name = "pyfragment"
version = "2026.2.0" version = "2026.2.3"
description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts." description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings."
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
requires-python = ">=3.10" requires-python = ">=3.10"
authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }] authors = [{ name = "bohd4nx" }]
keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"] keywords = [
"fragment",
"fragment-api",
"telegram",
"telegram-api",
"telegram-stars",
"telegram-premium",
"telegram-giveaway",
"telegram-ads",
"ton",
"ton-blockchain",
"tonkeeper",
"tonapi",
"anonymous-numbers",
"username-auctions",
"gift-marketplace",
"crypto-payments",
"nft-marketplace",
"web3",
"python-client",
"typed",
"asyncio",
]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Intended Audience :: Financial and Insurance Industry",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Natural Language :: English", "Natural Language :: English",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Framework :: AsyncIO", "Framework :: AsyncIO",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet", "Topic :: Internet",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Office/Business :: Financial", "Topic :: Office/Business :: Financial",
"Topic :: Office/Business :: Financial :: Investment",
"Typing :: Typed", "Typing :: Typed",
] ]
dependencies = [ dependencies = ["httpx>=0.25", "rookiepy>=0.5.6", "tonutils>=2.0.1"]
"httpx==0.28.1",
"rookiepy==0.5.6",
"tonutils==2.1.0",
]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
"pytest==9.0.3",
"pytest-asyncio==1.3.0",
"pytest-mock",
"mypy",
"ruff",
"black",
]
[project.urls] [project.urls]
Homepage = "https://github.com/bohd4nx/pyfragment" Homepage = "https://github.com/bohd4nx/pyfragment"
@@ -53,24 +69,27 @@ 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 = "py312" target-version = "py312"
[tool.ruff.lint] [tool.ruff.lint]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort # E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
select = ["E", "F", "W", "I"] select = ["E", "F", "W", "I", "UP"]
ignore = ["E501"] # E501 — line too long (covered by line-length above)
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
ignore = ["E501", "UP017"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"] "tests/*" = ["E402"]
"systests/*" = ["E402"] "systests/*" = ["E402"]
[tool.mypy]
python_version = "3.10"
strict = true
exclude = ["^systests/", "^examples/"]
-1
View File
@@ -1 +0,0 @@
-e .[dev]
-1
View File
@@ -1 +0,0 @@
-e .
+93 -7
View File
@@ -1,37 +1,62 @@
"""Tests for clean_decode() — TON BOC payload decoding.""" """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.types import ParseError from pyfragment.types import ParseError
from pyfragment.utils.decoder import clean_decode from pyfragment.utils.wallet.transaction import clean_decode
PAYLOADS = [ PAYLOAD_CASES = [
pytest.param( pytest.param(
"te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ", "te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ",
True,
id="stars", id="stars",
), ),
pytest.param( pytest.param(
"te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt", "te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt",
True,
id="premium", id="premium",
), ),
pytest.param( pytest.param(
"te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g", "te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g",
True,
id="topup", id="topup",
), ),
pytest.param(
"te6ccgEBAgEAfgABqA-KfqVP885dhccidjC3GwgBCkiH8LM_zUu0afyGCTWJwX1mDjdlf2rMa9UoQlD4UHUAF1jLlcMomlo5RJTwl8jnDDdfdhc7EgQQWPqFQ9IjyLPCAwEASgAAAAA1MCBUZWxlZ3JhbSBTdGFycyAKClJlZiNtOUpoWndBcFE",
False,
id="real_stars_50",
),
pytest.param(
"te6ccgEBAgEANgABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAzIG1vbnRocyAKClJlZgEAFCMzcFdKdGJkYnU",
False,
id="real_premium_3m",
),
pytest.param(
"te6ccgEBAwEAhgABqg-KfqWibdDaYaJCPUWWgvAIAQpIh_CzP81LtGn8hgk1icF9Zg43ZX9qzGvVKEJQ-FB1ABdYy5XDKJpaOUSU8JfI5ww3X3YXOxIEEFj6hUPSI8izwgMBAU4AAAAAMTAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmIzBoZ0RmNEYCAAQ5VA",
False,
id="real_stars_100k",
),
] ]
# Decode valid payload tests # Decode valid payload tests
@pytest.mark.parametrize("payload", PAYLOADS) @pytest.mark.parametrize(("payload", "strict_ref"), PAYLOAD_CASES)
def test_decode_payload(payload: str) -> None: def test_decode_payload(payload: str, strict_ref: bool) -> None:
result = clean_decode(payload) result = clean_decode(payload)
assert "Telegram" in result if isinstance(result, str):
assert re.search(r"Ref#[A-Za-z0-9]+", result), f"no Ref# in {result!r}" assert "Telegram" in result
assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}" if strict_ref:
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
@@ -44,3 +69,64 @@ def test_empty_payload_returns_empty_string() -> None:
def test_invalid_payload_raises_parse_error() -> None: def test_invalid_payload_raises_parse_error() -> None:
with pytest.raises(ParseError): with pytest.raises(ParseError):
clean_decode("!!!not-valid-base64!!!") clean_decode("!!!not-valid-base64!!!")
def test_decode_payload_accepts_base64url_alphabet() -> None:
class _FakeSlice:
def load_uint(self, _: int) -> int:
return 0
def load_snake_string(self) -> str:
return "Telegram Stars Ref#abc"
class _FakeCell:
def begin_parse(self) -> _FakeSlice:
return _FakeSlice()
raw = b"\xfb\xef\xff\x00"
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
with patch("pyfragment.utils.wallet.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.utils.wallet.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.utils.wallet.transaction.Cell.one_from_boc", return_value=fake_cell):
parsed = clean_decode(payload)
assert parsed is fake_cell
+56 -11
View File
@@ -1,5 +1,6 @@
"""Unit tests for process_transaction() — balance validation and broadcast retry logic.""" """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
@@ -45,10 +46,10 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
@contextmanager @contextmanager
def _patch_wallet(wallet: MagicMock): def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
with ( with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, patch("pyfragment.utils.wallet.transaction.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, patch("pyfragment.utils.wallet.transaction.WALLET_CLASSES") as mock_classes,
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -61,8 +62,8 @@ def _patch_wallet(wallet: MagicMock):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sufficient_balance_broadcasts() -> None: async def test_sufficient_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"): with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
wallet.transfer.assert_called_once() wallet.transfer.assert_called_once()
@@ -70,7 +71,7 @@ async def test_sufficient_balance_broadcasts() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_insufficient_balance_raises() -> None: async def test_insufficient_balance_raises() -> None:
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, 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)
@@ -79,15 +80,15 @@ async def test_insufficient_balance_raises() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_exact_minimum_balance_broadcasts() -> None: async def test_exact_minimum_balance_broadcasts() -> None:
wallet = _make_wallet(balance_nanotons=556_000_000) # exactly 0.5 + 0.056 TON wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"): with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_one_nanoton_below_minimum_raises() -> None: async def test_one_nanoton_below_minimum_raises() -> None:
wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton below threshold wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanoton below transaction amount threshold
with _patch_wallet(wallet): with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"): with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA) await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -102,6 +103,12 @@ 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)
@@ -116,7 +123,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
async def test_rate_limit_retries_and_succeeds() -> None: async def test_rate_limit_retries_and_succeeds() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")]) wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""): with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""):
result = await process_transaction(_make_client(), TRANSACTION_DATA) result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123" assert result == "abc123"
assert wallet.transfer.call_count == 2 assert wallet.transfer.call_count == 2
@@ -127,7 +134,45 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000) wallet = _make_wallet(balance_nanotons=1_000_000_000)
err = _provider_error(406, "Duplicate msg_seqno") err = _provider_error(406, "Duplicate msg_seqno")
wallet.transfer = AsyncMock(side_effect=[err, err, err]) wallet.transfer = AsyncMock(side_effect=[err, err, err])
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""): with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""):
with pytest.raises(TransactionError, match="seqno"): with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA) await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3 assert wallet.transfer.call_count == 3
@pytest.mark.asyncio
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.balance.get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient TON balance"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
@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.utils.wallet.transaction.clean_decode", return_value=""),
patch("pyfragment.utils.wallet.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
):
with pytest.raises(WalletError, match="Insufficient USDT balance"):
await process_transaction(
_make_client(),
transaction,
payment_method="usdt_ton",
required_payment_amount=12.5,
)
+96 -11
View File
@@ -32,23 +32,27 @@ async def test_purchase_stars_float_amount(client: FragmentClient) -> None:
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type] await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_purchase_stars_invalid_payment_method(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError, match="Invalid payment method"):
await client.purchase_stars("@user", amount=500, payment_method="btc") # type: ignore[arg-type]
# Stars purchase mocked tests # Stars purchase mocked tests
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_purchase_stars_success(client: FragmentClient) -> None: async def test_purchase_stars_success(client: FragmentClient) -> None:
call_mock = AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updateStarsBuyState
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
)
with ( with (
patch.object( patch.object(client, "call", call_mock),
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)),
): ):
@@ -60,6 +64,44 @@ async def test_purchase_stars_success(client: FragmentClient) -> None:
assert result.amount == 500 assert result.amount == 500
@pytest.mark.asyncio
async def test_purchase_stars_passes_payment_method(client: FragmentClient) -> None:
call_mock = AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updateStarsBuyState
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
)
proc_mock = AsyncMock(return_value=FAKE_TX_HASH)
with (
patch.object(client, "call", call_mock),
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_stars_mod, "process_transaction", proc_mock),
):
await client.purchase_stars("@user", amount=500, payment_method="usdt_ton")
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": {}})):
@@ -106,6 +148,12 @@ async def test_giveaway_stars_float_amount(client: FragmentClient) -> None:
await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type] await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_giveaway_stars_invalid_payment_method(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError, match="Invalid payment method"):
await client.giveaway_stars("@channel", winners=1, amount=500, payment_method="btc") # type: ignore[arg-type]
# Stars giveaway mocked tests # Stars giveaway mocked tests
@@ -135,6 +183,43 @@ 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="usdt_ton")
init_call = call_mock.await_args_list[1]
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": {}})):
+87
View File
@@ -26,6 +26,12 @@ async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
await client.purchase_premium("@user", months=0) 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
@@ -55,6 +61,44 @@ 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="usdt_ton")
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
@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": {}})):
@@ -89,6 +133,12 @@ async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None:
await client.giveaway_premium("@channel", winners=10, months=5) await client.giveaway_premium("@channel", winners=10, months=5)
@pytest.mark.asyncio
async def test_giveaway_premium_invalid_payment_method(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError, match="Invalid payment method"):
await client.giveaway_premium("@channel", winners=10, months=3, payment_method="btc") # type: ignore[arg-type]
# Premium giveaway mocked tests # Premium giveaway mocked tests
@@ -118,6 +168,43 @@ 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="usdt_ton")
init_call = call_mock.await_args_list[1]
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": {}})):
+12 -8
View File
@@ -1,4 +1,4 @@
"""Unit tests for get_wallet() — wallet address and TON balance lookup.""" """Unit tests for get_wallet() — wallet address/state with separate TON and USDT balances."""
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -7,7 +7,7 @@ import pytest
from pyfragment import FragmentClient, WalletInfo from pyfragment import FragmentClient, WalletInfo
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
# Wallet mocked tests # Wallet mocked tests (TON and USDT balances are returned separately)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -19,8 +19,9 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
mock_wallet.address.to_str.return_value = FAKE_ADDRESS mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with ( with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, patch("pyfragment.utils.wallet.info.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes,
patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=12.3456)),
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -31,7 +32,8 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
assert isinstance(result, WalletInfo) assert isinstance(result, WalletInfo)
assert result.address == FAKE_ADDRESS assert result.address == FAKE_ADDRESS
assert result.state == "active" assert result.state == "active"
assert result.balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4) assert result.ton_balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4)
assert result.usdt_balance == 12.3456
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -43,8 +45,9 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
mock_wallet.address.to_str.return_value = FAKE_ADDRESS mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with ( with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, patch("pyfragment.utils.wallet.info.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes,
patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=0.0)),
): ):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -52,5 +55,6 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
result = await client.get_wallet() result = await client.get_wallet()
assert result.balance == 0.0 assert result.ton_balance == 0.0
assert result.usdt_balance == 0.0
assert result.state == "uninit" assert result.state == "uninit"
+20 -1
View File
@@ -1,10 +1,13 @@
"""Unit tests for FragmentClient.call() — raw Fragment API access.""" """Unit tests for FragmentClient.call() — raw Fragment API access."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest import pytest
from pyfragment import FragmentClient from pyfragment import FragmentClient
from pyfragment.types import FragmentPageError
from pyfragment.utils.api 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
@@ -61,3 +64,19 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
_, _, _, 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:
"""fragment_request raises FragmentPageError on non-200 HTTP responses."""
response = MagicMock(spec=httpx.Response)
response.status_code = 429
session = AsyncMock(spec=httpx.AsyncClient)
session.post = AsyncMock(return_value=response)
with pytest.raises(FragmentPageError, match="429"):
await fragment_request(session, FAKE_HASH, {}, {"method": "anyMethod"})
+16 -1
View File
@@ -17,7 +17,7 @@ FAKE_JAR = [
] ]
def _mock_rookiepy(jar: list[dict] | None = None) -> MagicMock: def _mock_rookiepy(jar: list[dict[str, str]] | None = None) -> MagicMock:
mock = MagicMock() mock = MagicMock()
mock.chrome.return_value = jar if jar is not None else FAKE_JAR mock.chrome.return_value = jar if jar is not None else FAKE_JAR
return mock return mock
@@ -106,6 +106,21 @@ def test_empty_cookie_value_treated_as_missing() -> None:
get_cookies_from_browser("chrome") get_cookies_from_browser("chrome")
# expired cookie tests
def test_expired_cookie_raises() -> None:
expired_jar = [
{"name": "stel_ssid", "value": "abc123", "expires": "2020-01-01T00:00:00.000Z"},
{"name": "stel_dt", "value": "-120"},
{"name": "stel_token", "value": "tok_xyz"},
{"name": "stel_ton_token", "value": "ton_xyz"},
]
with patch(PATCH, _mock_rookiepy(expired_jar)):
with pytest.raises(CookieError, match="expired"):
get_cookies_from_browser("chrome")
# read failure tests # read failure tests
+21
View File
@@ -0,0 +1,21 @@
"""Unit tests for init payment amount parsing."""
from pyfragment.utils.parser import parse_required_payment_amount
def test_parse_required_payment_amount_ton_uses_amount() -> None:
init_response = {"amount": "0.326"}
assert parse_required_payment_amount(init_response) == 0.326
def test_parse_required_payment_amount_usdt_uses_amount() -> None:
init_response = {
"amount": "0.00075",
"content": '<span class="icon-before icon-usd">0.75</span>',
}
assert parse_required_payment_amount(init_response) == 0.00075
def test_parse_required_payment_amount_usdt_falls_back_to_amount() -> None:
init_response = {"amount": "1.25", "content": "<p>no usd icon</p>"}
assert parse_required_payment_amount(init_response) == 1.25
+3 -2
View File
@@ -1,5 +1,6 @@
import json import json
import os import os
from typing import cast
import pytest import pytest
@@ -14,13 +15,13 @@ from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
@pytest.fixture @pytest.fixture
def cookies(): def cookies() -> dict[str, str]:
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable.""" """Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
raw = os.environ.get("COOKIES_JSON") raw = os.environ.get("COOKIES_JSON")
if not raw: if not raw:
pytest.skip("COOKIES_JSON env var not set") pytest.skip("COOKIES_JSON env var not set")
try: try:
return json.loads(raw) return cast(dict[str, str], json.loads(raw))
except Exception as exc: except Exception as exc:
pytest.skip(f"Cookies unavailable — {exc}") pytest.skip(f"Cookies unavailable — {exc}")