18 Commits

Author SHA1 Message Date
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
49 changed files with 1164 additions and 453 deletions
+86 -86
View File
@@ -1,101 +1,101 @@
name: Bug report
description: Report an issue or unexpected behavior in pyfragment.
labels:
- bug
- bug
body:
- type: checkboxes
attributes:
label: Checklist
options:
- label: I am sure the error is coming from pyfragment code
required: true
- 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:
- type: checkboxes
attributes:
label: Checklist
options:
- label: I am sure the error is coming from pyfragment code
required: true
- label: I have searched the issue tracker for similar bug reports, including closed ones
required: true
- type: input
attributes:
label: Python version
description: Run `python --version` inside your virtualenv
placeholder: e.g. 3.12.3
validations:
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: pyfragment version
description: Run `pip show pyfragment` inside your virtualenv
placeholder: e.g. 2026.1.0
validations:
required: true
- type: input
attributes:
label: Operating system
placeholder: e.g. Ubuntu 22.04 / macOS 14 / Windows 11
validations:
required: true
- type: textarea
attributes:
label: Expected behavior
description: Describe what you expected to happen.
placeholder: e.g. Stars should be purchased and StarsResult returned.
validations:
required: true
- type: input
attributes:
label: Python version
description: Run `python --version` inside your virtualenv
placeholder: e.g. 3.12.3
validations:
required: true
- type: textarea
attributes:
label: Current behavior
description: Describe what is actually happening.
placeholder: e.g. ParseError is raised with status 400.
validations:
required: true
- type: input
attributes:
label: pyfragment version
description: Run `pip show pyfragment` inside your virtualenv
placeholder: e.g. 2026.1.0
validations:
required: true
- type: textarea
attributes:
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
- type: textarea
attributes:
label: Expected behavior
description: Describe what you expected to happen.
placeholder: e.g. Stars should be purchased and StarsResult returned.
validations:
required: true
- type: textarea
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
attributes:
label: Current behavior
description: Describe what is actually happening.
placeholder: e.g. ParseError is raised with status 400.
validations:
required: true
async def main():
client = FragmentClient(...)
result = await client.purchase_stars("@username", amount=100)
- type: textarea
attributes:
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())
render: python
- type: textarea
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
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
async def main():
client = FragmentClient(...)
result = await client.purchase_stars("@username", amount=100)
- 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.
asyncio.run(main())
render: python
- type: textarea
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
contact_links:
- name: Ask a question or start a discussion
url: https://github.com/bohd4nx/pyfragment/discussions
about: General questions, ideas, and community help go here — not in the issue tracker.
- name: Ask a question or start a discussion
url: https://github.com/bohd4nx/pyfragment/discussions
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
description: Suggest an improvement or new feature for pyfragment.
labels:
- enhancement
- enhancement
body:
- type: dropdown
attributes:
label: pyfragment version
description: Which version are you running?
options:
- latest
- older
- n/a
validations:
required: true
- type: dropdown
attributes:
label: pyfragment version
description: Which version are you running?
options:
- latest
- older
- n/a
validations:
required: true
- type: textarea
attributes:
label: Problem
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.
validations:
required: true
- type: textarea
attributes:
label: Problem
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.
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
description: Describe what you would like to see added or changed.
placeholder: e.g. Add a get_balance() method to FragmentClient.
validations:
required: true
- type: textarea
attributes:
label: Proposed solution
description: Describe what you would like to see added or changed.
placeholder: e.g. Add a get_balance() method to FragmentClient.
validations:
required: true
- type: textarea
attributes:
label: Alternatives considered
description: Any workarounds or alternative approaches you have thought of.
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
- type: textarea
attributes:
label: Alternatives considered
description: Any workarounds or alternative approaches you have thought of.
placeholder: e.g. I manually call the Fragment API, but it's not ergonomic.
- type: textarea
attributes:
label: Code example
description: A short example demonstrating the desired API, if applicable.
placeholder: |
balance = await client.get_balance()
print(balance.ton)
render: python
- type: textarea
attributes:
label: Code example
description: A short example demonstrating the desired API, if applicable.
placeholder: |
balance = await client.get_balance()
print(balance.ton)
render: python
- type: textarea
attributes:
label: Additional information
description: Any other context, screenshots, or references.
- type: textarea
attributes:
label: Additional information
description: Any other context, screenshots, or references.
+16 -16
View File
@@ -1,19 +1,19 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
+17 -23
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: ["**"]
branches: [ "**" ]
pull_request:
branches: ["**"]
branches: [ "**" ]
jobs:
lint:
@@ -14,15 +14,15 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.12"
- uses: actions/setup-python@v6
with:
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:
name: Tests (Python ${{ matrix.python-version }})
@@ -33,23 +33,17 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"] # 3.13, 3.14 are not supported by some dependencies yet
python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6
- name: Set up conda (Miniconda)
uses: conda-incubator/setup-miniconda@v3
with:
auto-update-conda: true
python-version: ${{ matrix.python-version }}
activate-environment: pyfragment-test
auto-activate-base: false
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install package and dev dependencies
shell: bash -el {0}
run: pip install ".[dev]"
- name: Install package and dev dependencies
run: pip install ".[dev]"
- name: Run tests
shell: bash -el {0}
run: pytest
- name: Run tests
run: pytest
+57 -57
View File
@@ -2,9 +2,9 @@ name: Publish
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [master]
workflows: [ "CI" ]
types: [ completed ]
branches: [ master ]
jobs:
version-check:
@@ -18,24 +18,24 @@ jobs:
is-new: ${{ steps.tag.outputs.is-new }}
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Read version
id: version
run: |
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "value=$value" >> $GITHUB_OUTPUT
- name: Read version
id: version
run: |
value=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
echo "value=$value" >> $GITHUB_OUTPUT
- name: Check tag
id: tag
run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "is-new=false" >> $GITHUB_OUTPUT
else
echo "is-new=true" >> $GITHUB_OUTPUT
fi
- name: Check tag
id: tag
run: |
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.value }}" | grep -q .; then
echo "is-new=false" >> $GITHUB_OUTPUT
else
echo "is-new=true" >> $GITHUB_OUTPUT
fi
build:
name: Build
@@ -46,24 +46,24 @@ jobs:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@v6.0.2
- uses: actions/checkout@v6
- uses: actions/setup-python@v6.2.0
with:
python-version: "3.12"
- uses: actions/setup-python@v6
with:
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
with:
name: dist
path: dist/*
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/*
publish:
name: Publish to PyPI
needs: [version-check, build]
needs: [ version-check, build ]
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -74,16 +74,16 @@ jobs:
id-token: write
steps:
- uses: actions/download-artifact@v8.0.1
with:
name: dist
path: dist
- uses: actions/download-artifact@v8
with:
name: dist
path: dist
- uses: pypa/gh-action-pypi-publish@v1.13.0
- uses: pypa/gh-action-pypi-publish@release/v1
release:
name: GitHub Release
needs: [version-check, build]
needs: [ version-check, build ]
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@@ -91,27 +91,27 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/download-artifact@v8.0.1
with:
name: dist
path: dist
- uses: actions/download-artifact@v8
with:
name: dist
path: dist
- name: Extract latest changelog entry
id: changelog
run: |
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$body" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Extract latest changelog entry
id: changelog
run: |
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$body" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- uses: softprops/action-gh-release@v2.6.1
with:
tag_name: v${{ needs.version-check.outputs.version }}
name: v${{ needs.version-check.outputs.version }}
files: dist/*
body: ${{ steps.changelog.outputs.body }}
make_latest: true
- uses: softprops/action-gh-release@v3
with:
tag_name: v${{ needs.version-check.outputs.version }}
name: v${{ needs.version-check.outputs.version }}
files: dist/*
body: ${{ steps.changelog.outputs.body }}
make_latest: true
+61
View File
@@ -7,6 +7,54 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
---
## [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
### Added
@@ -33,37 +81,44 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
### Added
**Giveaways**
- `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
- `StarsGiveawayResult`, `PremiumGiveawayResult` result types
**Telegram Ads**
- `recharge_ads(account, amount)` — top up a Telegram Ads account; 11 000 000 000 TON
- `AdsRechargeResult` result type
**Marketplace**
- `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_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search Fragment gifts; `attr` accepts `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}`
- `UsernamesResult`, `NumbersResult`, `GiftsResult` result types
**Anonymous numbers**
- `get_login_code(number)` — fetch the current pending login code
- `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)
- `LoginCodeResult`, `TerminateSessionsResult` result types; `AnonymousNumberError` exception
**Raw API**
- `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
**Examples**
- `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/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`
### Changed
- All result types now expose a unified `amount` field (`months` and `stars` removed)
- `__repr__` includes the unit — `3 months`, `500 stars`, etc.
- `timestamp` removed from all result dataclasses
@@ -76,15 +131,18 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
## [2026.0.2] — 2026-03-20
### Added
- `timeout` parameter on `FragmentClient` (default `30.0` s) — passed through to every HTTP request
### Changed
- 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
- 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
### Fixed
- mypy: missing return path in `process_transaction` after retry loop
- mypy: `cookies` union-attr error in `FragmentClient.__init__`
@@ -93,6 +151,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
## [2026.0.1] — 2026-03-16
### Added
- Initial stable release of `pyfragment`
- `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)
@@ -104,6 +163,8 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
- `py.typed` marker — full PEP 561 typing support for type-checkers
- `__repr__` on all result types for readable debug output
[2026.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.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
+22 -43
View File
@@ -4,15 +4,14 @@
<h1 style="margin-top: 24px;">Fragment API</h1>
<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>
[![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)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=lightgrey)](LICENSE)
[![Stars](https://img.shields.io/github/stars/bohd4nx/pyfragment?style=flat&color=yellow)](https://github.com/bohd4nx/pyfragment/stargazers)
[![CI](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=green)](https://github.com/bohd4nx/pyfragment/blob/master/LICENSE)
[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
import asyncio
from pyfragment import (
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
)
from pyfragment import FragmentClient
async def main() -> None:
async with FragmentClient(
seed="word1 word2 ... word24", # 24-word TON wallet mnemonic
api_key="YOUR_TONAPI_KEY", # from tonconsole.com
seed="word1 word2 ... word24",
api_key="YOUR_TONAPI_KEY",
cookies={
"stel_ssid": "...",
"stel_dt": "...",
@@ -98,40 +88,29 @@ async def main() -> None:
"stel_ton_token": "...",
},
) as client:
try:
# Purchase 6 months of Telegram Premium
result = await client.purchase_premium("@username", months=6)
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
wallet = await client.get_wallet()
print(f"Wallet: {wallet.address} | TON: {wallet.ton_balance} | USDT: {wallet.usdt_balance}")
# Purchase 500 Stars
result = await client.purchase_stars("@username", amount=500)
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
recipient = "https://t.me/username" # also supports: @username, username
# Top up 10 TON to Telegram balance
# wallet must hold at least amount + ~0.056 TON for gas
result = await client.topup_ton("@username", amount=10)
print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}")
stars = await client.purchase_stars(recipient, amount=500, payment_method="usdt_ton")
print(f"Stars sent: {stars.amount} to {stars.username} | tx: {stars.transaction_id}")
except UserNotFoundError:
print(f"User was not found on fragment.com — check the username and try again.")
except WalletError as e:
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
except CookieError:
print("Authentication failed — session cookies are missing or expired. Refresh them and retry.")
except TransactionError as e:
print(f"Transaction failed to broadcast on-chain: {e}")
except ConfigurationError as e:
print(f"Invalid argument: {e}")
except FragmentAPIError as e:
print(f"Unexpected response from Fragment API: {e}")
except FragmentError as e:
# catch-all for any other pyfragment error
print(f"Unexpected error: {e}")
premium = await client.purchase_premium(recipient, months=6, payment_method="ton")
print(f"Premium sent: {premium.amount} months to {premium.username} | tx: {premium.transaction_id}")
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">
+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.
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
@@ -35,7 +35,8 @@ async def main() -> None:
wallet = await client.get_wallet()
print(f"Address: {wallet.address}")
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__":
+1 -1
View File
@@ -2,7 +2,7 @@
Example: recharge your own Telegram Ads account with 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
+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.
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
@@ -24,15 +26,21 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
CHANNEL = "@channel"
CHANNEL = "https://t.me/channel"
WINNERS = 10 # 124 000
MONTHS = 3 # 3, 6 or 12
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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:
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
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.
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
@@ -24,15 +26,21 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
CHANNEL = "@channel"
CHANNEL = "https://t.me/channel"
WINNERS = 3 # 15
AMOUNT = 1000 # 5001 000 000 stars per winner
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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:
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
return
+10 -2
View File
@@ -3,6 +3,8 @@ Example: purchase Telegram Premium for a user.
Supported durations: 3, 6, or 12 months.
Set show_sender=False to send anonymously.
payment_method can be "ton" or "usdt_ton".
Username can be "@username", "username", or "https://t.me/username".
"""
import asyncio
@@ -24,14 +26,20 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
USERNAME = "@username"
USERNAME = "https://t.me/username"
MONTHS = 3 # 3, 6 or 12
PAYMENT_METHOD = "ton" # "ton" or "usdt_ton"
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
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.
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
@@ -24,14 +26,20 @@ COOKIES = {
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
USERNAME = "@username"
USERNAME = "https://t.me/username"
AMOUNT = 500 # 501 000 000 stars
PAYMENT_METHOD = "usdt_ton" # "ton" or "usdt_ton"
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
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:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
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.
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
+23 -15
View File
@@ -15,6 +15,7 @@ from pyfragment.types import (
CookieError,
CookieResult,
FragmentAPIError,
# exceptions
FragmentError,
FragmentPageError,
GiftsResult,
@@ -22,9 +23,12 @@ from pyfragment.types import (
NumbersResult,
OperationError,
ParseError,
# literal types
PaymentMethod,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
# results
StarsResult,
TerminateSessionsResult,
TransactionError,
@@ -41,31 +45,35 @@ __version__: str = version("pyfragment")
__all__ = [
"__version__",
"FragmentClient",
"AdsRechargeResult",
# results
"StarsResult",
"StarsGiveawayResult",
"PremiumResult",
"PremiumGiveawayResult",
"WalletInfo",
"AdsTopupResult",
"AdsRechargeResult",
"CookieResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult",
"UsernamesResult",
"WalletInfo",
"ClientError",
"ConfigurationError",
"CookieError",
"CookieResult",
"FragmentAPIError",
# exceptions
"FragmentError",
"FragmentAPIError",
"FragmentPageError",
"ConfigurationError",
"UserNotFoundError",
"WalletError",
"VerificationError",
"TransactionError",
"AnonymousNumberError",
"ClientError",
"CookieError",
"OperationError",
"ParseError",
"TransactionError",
"UnexpectedError",
"UserNotFoundError",
"VerificationError",
"WalletError",
# literal types
"PaymentMethod",
]
+36 -16
View File
@@ -36,6 +36,7 @@ from pyfragment.types.constants import (
FRAGMENT_BASE_URL,
REQUIRED_COOKIE_KEYS,
SUPPORTED_WALLET_VERSIONS,
PaymentMethod,
WalletVersion,
)
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
@@ -77,7 +78,7 @@ class FragmentClient:
self,
seed: str,
api_key: str,
cookies: dict | str,
cookies: dict[str, Any] | str,
wallet_version: str = "V5R1",
timeout: float = DEFAULT_TIMEOUT,
) -> None:
@@ -98,7 +99,7 @@ class FragmentClient:
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict, cookies).get(k, "")).strip()]
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict[str, Any], cookies).get(k, "")).strip()]
if missing_keys:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
@@ -112,11 +113,11 @@ class FragmentClient:
self.seed: str = seed.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.timeout: float = timeout
async def __aenter__(self) -> "FragmentClient":
async def __aenter__(self) -> FragmentClient:
return self
async def __aexit__(self, *_: object) -> None:
@@ -125,31 +126,45 @@ class FragmentClient:
def __repr__(self) -> str:
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.
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``.
show_sender: Show your name as the sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
: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.
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``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
"""
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:
"""Top up TON to a recipient's Telegram balance.
@@ -178,11 +193,12 @@ class FragmentClient:
return await recharge_ads(self, account, amount)
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:
: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)
@@ -191,38 +207,42 @@ class FragmentClient:
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = "ton",
) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel.
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``.
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
return await giveaway_stars(self, channel, winners, amount)
return await giveaway_stars(self, channel, winners, amount, payment_method)
async def giveaway_premium(
self,
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = "ton",
) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel.
Args:
channel: Channel username (with or without ``@``).
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — positive integer.
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
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:
"""Fetch the current pending login code for an anonymous number.
+3 -3
View File
@@ -22,7 +22,7 @@ def _strip_plus(number: str) -> str:
return number.lstrip("+") if isinstance(number, str) else number
async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResult:
async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult:
"""Fetch the current pending login code for an anonymous number.
Args:
@@ -58,7 +58,7 @@ async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResu
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def toggle_login_codes(client: "FragmentClient", number: str, can_receive: bool) -> None:
async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None:
"""Enable or disable login code delivery for an anonymous number.
Args:
@@ -87,7 +87,7 @@ async def toggle_login_codes(client: "FragmentClient", number: str, can_receive:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def terminate_sessions(client: "FragmentClient", number: str) -> TerminateSessionsResult:
async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult:
"""Terminate all active Telegram sessions for an anonymous number.
This is a two-step operation: Fragment first returns a confirmation hash,
+26 -6
View File
@@ -12,26 +12,28 @@ from pyfragment.types import (
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE
from pyfragment.utils import get_account_info, process_transaction
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def giveaway_premium(
client: "FragmentClient",
client: FragmentClient,
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = "ton",
) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel.
Args:
client: Authenticated :class:`FragmentClient` instance.
channel: Channel username (with or without ``@``).
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``24 000``.
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
@@ -47,6 +49,13 @@ async def giveaway_premium(
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
if months not in (3, 6, 12):
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
if payment_method not in SUPPORTED_PAYMENT_METHODS:
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
)
)
try:
result = await client.call(
@@ -60,9 +69,15 @@ async def giveaway_premium(
result = await client.call(
"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,
)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
@@ -81,7 +96,12 @@ async def giveaway_premium(
if transaction.get("need_verify"):
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(
transaction_id=tx_hash,
channel=channel,
+26 -6
View File
@@ -12,26 +12,28 @@ from pyfragment.types import (
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE
from pyfragment.utils import get_account_info, process_transaction
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def giveaway_stars(
client: "FragmentClient",
client: FragmentClient,
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = "ton",
) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel.
Args:
client: Authenticated :class:`FragmentClient` instance.
channel: Channel username (with or without ``@``).
channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``.
winners: Number of winners — integer from ``1`` to ``5``.
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
@@ -47,6 +49,13 @@ async def giveaway_stars(
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
if payment_method not in SUPPORTED_PAYMENT_METHODS:
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
)
)
try:
result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
@@ -56,9 +65,15 @@ async def giveaway_stars(
result = await client.call(
"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,
)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
@@ -77,7 +92,12 @@ async def giveaway_stars(
if transaction.get("need_verify"):
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(
transaction_id=tx_hash,
channel=channel,
+30 -6
View File
@@ -13,21 +13,28 @@ from pyfragment.types import (
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE
from pyfragment.utils import get_account_info, process_transaction
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> 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.
Args:
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``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
@@ -40,6 +47,13 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
"""
if months not in (3, 6, 12):
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
if payment_method not in SUPPORTED_PAYMENT_METHODS:
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
)
)
try:
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
@@ -52,7 +66,12 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
page_url=PREMIUM_PAGE,
)
result = await client.call("initGiftPremiumRequest", {"recipient": recipient, "months": months}, page_url=PREMIUM_PAGE)
result = await client.call(
"initGiftPremiumRequest",
{"recipient": recipient, "months": months, "payment_method": payment_method},
page_url=PREMIUM_PAGE,
)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
@@ -72,7 +91,12 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
if transaction.get("need_verify"):
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)
except FragmentError:
+32 -6
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import time
from typing import TYPE_CHECKING
from pyfragment.types import (
@@ -12,21 +13,24 @@ from pyfragment.types import (
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, STARS_PAGE
from pyfragment.utils import get_account_info, process_transaction
from pyfragment.types.constants import DEVICE, STARS_PAGE, SUPPORTED_PAYMENT_METHODS, PaymentMethod
from pyfragment.utils import get_account_info, parse_required_payment_amount, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> 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.
Args:
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``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``.
Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
@@ -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):
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
if payment_method not in SUPPORTED_PAYMENT_METHODS:
raise ConfigurationError(
ConfigurationError.INVALID_PAYMENT_METHOD.format(
method=payment_method,
supported=", ".join(sorted(SUPPORTED_PAYMENT_METHODS)),
)
)
try:
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
@@ -46,7 +57,17 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s
if not recipient:
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")
if not req_id:
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"):
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)
except FragmentError:
+1 -1
View File
@@ -18,7 +18,7 @@ if TYPE_CHECKING:
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.
Args:
+2 -2
View File
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
async def search_gifts(
client: "FragmentClient",
client: FragmentClient,
query: str = "",
collection: str | None = None,
sort: str | None = None,
@@ -45,7 +45,7 @@ async def search_gifts(
FragmentAPIError: If the Fragment API returns an error.
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:
data["collection"] = collection
if sort is not None:
+2 -2
View File
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
async def search_numbers(
client: "FragmentClient",
client: FragmentClient,
query: str = "",
sort: str | None = None,
filter: str | None = None,
@@ -37,7 +37,7 @@ async def search_numbers(
FragmentAPIError: If the Fragment API returns an error.
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:
data["sort"] = sort
if filter is not None:
+2 -2
View File
@@ -11,7 +11,7 @@ if TYPE_CHECKING:
async def search_usernames(
client: "FragmentClient",
client: FragmentClient,
query: str = "",
sort: str | None = None,
filter: str | None = None,
@@ -37,7 +37,7 @@ async def search_usernames(
FragmentAPIError: If the Fragment API returns an error.
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:
data["sort"] = sort
if filter is not None:
+1 -1
View File
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
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.
Args:
+3
View File
@@ -1,3 +1,4 @@
from pyfragment.types.constants import PaymentMethod
from pyfragment.types.exceptions import (
AnonymousNumberError,
ClientError,
@@ -61,4 +62,6 @@ __all__ = [
"TerminateSessionsResult",
"UsernamesResult",
"WalletInfo",
# literal types
"PaymentMethod",
]
+15 -4
View File
@@ -5,6 +5,10 @@ from typing import Any, Literal, get_args
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
# Payment methods
PaymentMethod = Literal["ton", "usdt_ton"]
SUPPORTED_PAYMENT_METHODS: frozenset[str] = frozenset(get_args(PaymentMethod))
# Single source of truth for supported wallet versions
WalletVersion = Literal["V4R2", "V5R1"]
SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
@@ -12,8 +16,12 @@ SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
# Minimum wallet balance required to cover TON network gas fees.
MIN_TON_BALANCE: float = 0.056
# Minimum TON balance threshold required for payment flows.
MIN_TON_BALANCE: float = 0.33
# USDT (TON) jetton metadata used for payment-method balance checks.
USDT_TON_MASTER_ADDRESS: str = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs"
MIN_USDT_BALANCE: float = 0.75
# Default HTTP request timeout in seconds.
DEFAULT_TIMEOUT: float = 30.0
@@ -74,12 +82,15 @@ BASE_HEADERS: dict[str, str] = {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"origin": FRAGMENT_BASE_URL,
"priority": "u=1, i",
"sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua-mobile": "?1",
"sec-ch-ua-platform": '"Android"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
),
"x-requested-with": "XMLHttpRequest",
}
+10 -9
View File
@@ -16,7 +16,7 @@ class ConfigurationError(ClientError):
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
INVALID_API_KEY = (
"Invalid Tonapi API key: expected at least 68 characters, got {length}. " "Generate a key at https://tonconsole.com."
"Invalid Tonapi API key: expected at least 68 characters, got {length}. Generate a key at https://tonconsole.com."
)
INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months."
INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000."
@@ -28,6 +28,7 @@ class ConfigurationError(ClientError):
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000."
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000."
INVALID_PAYMENT_METHOD = "Invalid payment method '{method}'. Supported values: {supported}."
class CookieError(ClientError):
@@ -40,12 +41,13 @@ class CookieError(ClientError):
)
UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}."
BROWSER_READ_FAILED = (
"Failed to read {browser} cookies: {exc}. " "Make sure {browser} is installed and you are logged in to {url}."
"Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}."
)
MISSING_BROWSER_KEYS = (
"Fragment cookies not found in {browser}: {keys}. "
"Make sure you are logged in to {url} and have connected your TON wallet in {browser}."
)
EXPIRED = "Fragment session cookie expired at {expires}. Log in to fragment.com in your browser and extract fresh cookies."
class FragmentAPIError(FragmentError):
@@ -74,7 +76,7 @@ class UserNotFoundError(FragmentAPIError):
"""Raised when the target Telegram user is not found on Fragment."""
NOT_FOUND = (
"Telegram user '{username}' was not found on Fragment. " "Double-check the username and make sure the account exists."
"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."""
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_SSL = (
@@ -129,11 +131,10 @@ class OperationError(FragmentError):
class WalletError(OperationError):
"""Raised for TON wallet issues (connection, balance, account info)."""
LOW_BALANCE = (
"Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required "
"(transaction amount + {gas:.3f} TON gas reserve)."
)
BALANCE_CHECK_FAILED = "Failed to fetch wallet balance: {exc}"
LOW_TON_BALANCE = "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."
TON_BALANCE_CHECK_FAILED = "Failed to fetch TON balance: {exc}"
USDT_BALANCE_CHECK_FAILED = "Failed to fetch USDT balance: {exc}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}"
WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
+6 -2
View File
@@ -27,10 +27,14 @@ class WalletInfo:
address: str
state: str
balance: float
ton_balance: float
usdt_balance: float
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
+2 -1
View File
@@ -1,6 +1,6 @@
from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser
from pyfragment.utils.decoder import clean_decode
from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code
from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code, parse_required_payment_amount
from pyfragment.utils.http import (
execute_transaction_request,
fragment_request,
@@ -17,6 +17,7 @@ __all__ = [
"parse_auction_rows",
"parse_gift_items",
"parse_login_code",
"parse_required_payment_amount",
"execute_transaction_request",
"fragment_request",
"get_account_info",
+9 -2
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
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
@@ -34,7 +36,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
try:
jar: list[dict] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
except Exception as exc:
raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc
@@ -59,6 +61,11 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
continue
break
if expires_iso:
expires_dt = datetime.fromisoformat(expires_iso)
if expires_dt < datetime.now(timezone.utc):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
return CookieResult(
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
expires=expires_iso,
+11 -5
View File
@@ -7,7 +7,7 @@ from ton_core import Cell
from pyfragment.types import ParseError
def clean_decode(payload: str) -> str:
def clean_decode(payload: str) -> str | Cell:
"""Decode a base64-encoded BOC payload to a plain-text comment string.
Fragment transaction payloads are BOC-serialised TVM cells. This function
@@ -18,7 +18,8 @@ def clean_decode(payload: str) -> str:
payload: Base64url-encoded BOC string (padding is added automatically).
Returns:
Decoded comment string, or ``""`` for an empty payload.
Decoded comment string, ``""`` for an empty payload, or raw ``Cell``
when payload is a non-UTF8 binary body.
Raises:
ParseError: If the payload cannot be decoded or parsed.
@@ -28,10 +29,15 @@ def clean_decode(payload: str) -> str:
return ""
s += "=" * (-len(s) % 4)
try:
boc = base64.b64decode(s)
# Fragment may return URL-safe base64 ("-"/"_") in transaction payloads.
boc = base64.b64decode(s, altchars=b"-_", validate=True)
cell = Cell.one_from_boc(boc)
sl = cell.begin_parse()
sl.load_uint(32) # op code — always 0 for text comment
return sl.load_snake_string().strip()
sl.load_uint(32) # op code
try:
return sl.load_snake_string().strip()
except UnicodeDecodeError:
# Some Fragment payloads are binary TVM cells rather than text comments.
return cell
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
+9
View File
@@ -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
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
+19 -8
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import asyncio
import random
import re
from typing import Any
from typing import Any, cast
import httpx
@@ -80,7 +82,7 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any
ParseError: If the response body cannot be decoded as JSON.
"""
try:
return response.json()
return cast(dict[str, Any], response.json())
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
@@ -106,12 +108,21 @@ async def fragment_request(
Returns:
Parsed API response as a dict.
"""
resp = await session.post(
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
headers=headers,
data=data,
)
return parse_json_response(resp, data.get("method", "request"))
for attempt in range(3):
resp = await session.post(
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
headers=headers,
data=data,
)
if resp.status_code == 429 and attempt < 2:
await asyncio.sleep(1 + attempt + random.uniform(0, 0.5))
continue
if resp.status_code != 200:
raise FragmentPageError(
FragmentPageError.BAD_STATUS.format(status=resp.status_code, url=f"{FRAGMENT_BASE_URL}/api")
)
return parse_json_response(resp, data.get("method", "request"))
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
async def execute_transaction_request(
+109 -16
View File
@@ -2,22 +2,97 @@ from __future__ import annotations
import asyncio
import base64
import random
import ssl
from typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient
from tonutils.contracts.jetton import get_wallet_address_get_method, get_wallet_data_get_method
from tonutils.exceptions import ProviderResponseError
from pyfragment.types import TransactionError, WalletError, WalletInfo
from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES
from pyfragment.types.constants import (
MIN_TON_BALANCE,
MIN_USDT_BALANCE,
USDT_TON_MASTER_ADDRESS,
WALLET_CLASSES,
PaymentMethod,
)
from pyfragment.utils.decoder import clean_decode
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def process_transaction(client: "FragmentClient", transaction_data: dict[str, Any]) -> str:
async def _get_usdt_balance(ton: Any, wallet_address: str) -> float:
"""Return wallet USDT balance via tonutils jetton get-methods."""
try:
jetton_wallet_address = await get_wallet_address_get_method(
client=ton,
address=USDT_TON_MASTER_ADDRESS,
owner_address=wallet_address,
)
wallet_data = await get_wallet_data_get_method(client=ton, address=jetton_wallet_address)
raw_balance = int(wallet_data[0]) if wallet_data else 0
return float(raw_balance) / 1_000_000.0
except ProviderResponseError as exc:
# No jetton wallet deployed yet -> effectively zero USDT balance.
if exc.code == 404:
return 0.0
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
except Exception as exc:
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
async def _check_ton_payment_balance(
balance_ton: float,
amount_ton: float,
required_payment_amount: float | None,
) -> None:
"""Validate balance requirements for TON payment method."""
tx_price_ton = amount_ton
if required_payment_amount is not None and required_payment_amount > 0:
tx_price_ton = max(tx_price_ton, required_payment_amount)
required_ton = max(tx_price_ton, MIN_TON_BALANCE)
if balance_ton < required_ton:
raise WalletError(
WalletError.LOW_TON_BALANCE.format(
balance=balance_ton,
required=required_ton,
)
)
async def _check_usdt_payment_balance(
balance_ton: float,
required_payment_amount: float | None,
ton: Any,
wallet_address: str,
) -> None:
"""Validate balance requirements for USDT payment method."""
# USDT payment still needs TON for network fees.
if balance_ton < MIN_TON_BALANCE:
raise WalletError(
WalletError.LOW_TON_BALANCE.format(
balance=balance_ton,
required=MIN_TON_BALANCE,
)
)
usdt_balance = await _get_usdt_balance(ton, wallet_address)
required_usdt = required_payment_amount if required_payment_amount is not None else MIN_USDT_BALANCE
if usdt_balance < required_usdt:
raise WalletError(WalletError.LOW_USDT_BALANCE.format(balance=usdt_balance, required=required_usdt))
async def process_transaction(
client: FragmentClient,
transaction_data: dict[str, Any],
payment_method: PaymentMethod = "ton",
required_payment_amount: float | None = None,
) -> str:
"""Sign and broadcast a Fragment transaction to the TON network.
Validates the payload structure, checks the wallet balance, decodes the
@@ -26,6 +101,8 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict[s
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.
@@ -34,7 +111,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict[s
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"]:
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
raise TransactionError(TransactionError.INVALID_PAYLOAD)
message = transaction_data["transaction"]["messages"][0]
@@ -44,20 +121,32 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict[s
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
# Check balance covers transaction amount + gas reserve
# Check balance covers selected payment flow requirements.
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))
wallet_address = wallet.address.to_str(False, False)
if payment_method == "ton":
await _check_ton_payment_balance(
balance_ton,
amount_ton,
required_payment_amount,
)
else:
await _check_usdt_payment_balance(
balance_ton,
required_payment_amount,
ton,
wallet_address,
)
except WalletError:
raise
except Exception as exc:
raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc
raise WalletError(WalletError.TON_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
try:
payload = clean_decode(message["payload"])
raw_payload = str(message.get("payload", ""))
payload = clean_decode(raw_payload)
for attempt in range(3):
try:
@@ -66,15 +155,15 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict[s
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
return result.normalized_hash
return str(result.normalized_hash)
except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0:
await asyncio.sleep(1)
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)
await asyncio.sleep(2 + random.uniform(0, 1))
continue
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
raise
@@ -91,7 +180,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict[s
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
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
@@ -122,14 +211,15 @@ async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
async def get_wallet_info(client: "FragmentClient") -> "WalletInfo":
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.
:class:`WalletInfo` with ``address``, ``state``, ``balance`` in TON,
and ``usdt_balance`` in USDT.
Raises:
WalletError: If the wallet state cannot be fetched.
@@ -139,10 +229,13 @@ async def get_wallet_info(client: "FragmentClient") -> "WalletInfo":
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,
balance=round(wallet.balance / 1_000_000_000, 4),
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
+45 -26
View File
@@ -4,44 +4,60 @@ build-backend = "hatchling.build"
[project]
name = "pyfragment"
version = "2026.2.0"
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."
version = "2026.2.2"
description = "Async Python client for the Fragment API. Buy Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }]
keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"]
authors = [{ name = "bohd4nx" }]
keywords = [
"fragment",
"fragment-api",
"telegram",
"telegram-api",
"telegram-stars",
"telegram-premium",
"telegram-giveaway",
"telegram-ads",
"ton",
"ton-blockchain",
"tonkeeper",
"tonapi",
"anonymous-numbers",
"username-auctions",
"gift-marketplace",
"crypto-payments",
"nft-marketplace",
"web3",
"python-client",
"typed",
"asyncio",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: Financial and Insurance Industry",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Framework :: AsyncIO",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Office/Business :: Financial",
"Topic :: Office/Business :: Financial :: Investment",
"Typing :: Typed",
]
dependencies = [
"httpx==0.28.1",
"rookiepy==0.5.6",
"tonutils==2.1.0",
]
dependencies = ["httpx>=0.25", "rookiepy>=0.5.6", "tonutils>=2.0.1"]
[project.optional-dependencies]
dev = [
"pytest==9.0.3",
"pytest-asyncio==1.3.0",
"pytest-mock",
"mypy",
"ruff",
"black",
]
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
[project.urls]
Homepage = "https://github.com/bohd4nx/pyfragment"
@@ -53,24 +69,27 @@ Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
packages = ["pyfragment"]
[tool.pytest.ini_options]
testpaths = ["tests"]
testpaths = ["tests"]
python_files = ["[0-9][0-9][0-9]_test_*.py"]
asyncio_mode = "auto"
addopts = "-v --tb=short"
[tool.black]
line-length = 128
target-version = ["py312"]
addopts = "-v --tb=short"
[tool.ruff]
line-length = 128
target-version = "py312"
[tool.ruff.lint]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort
select = ["E", "F", "W", "I"]
ignore = ["E501"]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
select = ["E", "F", "W", "I", "UP"]
# E501 — line too long (covered by line-length above)
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
ignore = ["E501", "UP017"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["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 .
+92 -6
View File
@@ -1,37 +1,62 @@
"""Tests for clean_decode() — TON BOC payload decoding."""
import base64
import re
from unittest.mock import patch
import pytest
from ton_core import Cell
from pyfragment.types import ParseError
from pyfragment.utils.decoder import clean_decode
PAYLOADS = [
PAYLOAD_CASES = [
pytest.param(
"te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ",
True,
id="stars",
),
pytest.param(
"te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt",
True,
id="premium",
),
pytest.param(
"te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g",
True,
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
@pytest.mark.parametrize("payload", PAYLOADS)
def test_decode_payload(payload: str) -> None:
@pytest.mark.parametrize(("payload", "strict_ref"), PAYLOAD_CASES)
def test_decode_payload(payload: str, strict_ref: bool) -> None:
result = clean_decode(payload)
assert "Telegram" in result
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}"
if isinstance(result, str):
assert "Telegram" in result
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
@@ -44,3 +69,64 @@ def test_empty_payload_returns_empty_string() -> None:
def test_invalid_payload_raises_parse_error() -> None:
with pytest.raises(ParseError):
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.decoder.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.decoder.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.decoder.Cell.one_from_boc", return_value=fake_cell):
parsed = clean_decode(payload)
assert parsed is fake_cell
+50 -5
View File
@@ -1,5 +1,6 @@
"""Unit tests for process_transaction() — balance validation and broadcast retry logic."""
from collections.abc import Generator
from contextlib import contextmanager
from unittest.mock import AsyncMock, MagicMock, patch
@@ -45,7 +46,7 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
@contextmanager
def _patch_wallet(wallet: MagicMock):
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
@@ -61,7 +62,7 @@ def _patch_wallet(wallet: MagicMock):
@pytest.mark.asyncio
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"):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
@@ -70,7 +71,7 @@ async def test_sufficient_balance_broadcasts() -> None:
@pytest.mark.asyncio
async def test_insufficient_balance_raises() -> None:
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, below threshold
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -79,7 +80,7 @@ async def test_insufficient_balance_raises() -> None:
@pytest.mark.asyncio
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"):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
@@ -87,7 +88,7 @@ async def test_exact_minimum_balance_broadcasts() -> None:
@pytest.mark.asyncio
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 pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
@@ -102,6 +103,12 @@ async def test_invalid_payload_raises() -> None:
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
async def test_balance_check_failed_raises_wallet_error() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000)
@@ -131,3 +138,41 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3
@pytest.mark.asyncio
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE
with _patch_wallet(wallet), patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient TON balance"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
@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.clean_decode", return_value=""),
patch("pyfragment.utils.wallet._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]
@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
@pytest.mark.asyncio
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 (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
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", AsyncMock(return_value=FAKE_TX_HASH)),
):
@@ -60,6 +64,44 @@ async def test_purchase_stars_success(client: FragmentClient) -> None:
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
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
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]
@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
@@ -135,6 +183,43 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None:
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
async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
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)
@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
@@ -55,6 +61,44 @@ async def test_purchase_premium_success(client: FragmentClient) -> None:
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
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
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)
@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
@@ -118,6 +168,43 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None:
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
async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
+8 -4
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
@@ -7,7 +7,7 @@ import pytest
from pyfragment import FragmentClient, WalletInfo
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
@@ -21,6 +21,7 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=12.3456)),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
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 result.address == FAKE_ADDRESS
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
@@ -45,6 +47,7 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
patch("pyfragment.utils.wallet._get_usdt_balance", AsyncMock(return_value=0.0)),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
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()
assert result.balance == 0.0
assert result.ton_balance == 0.0
assert result.usdt_balance == 0.0
assert result.state == "uninit"
+20 -1
View File
@@ -1,10 +1,13 @@
"""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
from pyfragment import FragmentClient
from pyfragment.types import FragmentPageError
from pyfragment.utils.http import fragment_request
from tests.shared import FAKE_HASH, FAKE_RESPONSE
# 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
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.chrome.return_value = jar if jar is not None else FAKE_JAR
return mock
@@ -106,6 +106,21 @@ def test_empty_cookie_value_treated_as_missing() -> None:
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
+21
View File
@@ -0,0 +1,21 @@
"""Unit tests for init payment amount parsing."""
from pyfragment.utils.html 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 os
from typing import cast
import pytest
@@ -14,13 +15,13 @@ from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
@pytest.fixture
def cookies():
def cookies() -> dict[str, str]:
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
raw = os.environ.get("COOKIES_JSON")
if not raw:
pytest.skip("COOKIES_JSON env var not set")
try:
return json.loads(raw)
return cast(dict[str, str], json.loads(raw))
except Exception as exc:
pytest.skip(f"Cookies unavailable — {exc}")