9 Commits

95 changed files with 1237 additions and 5316 deletions
+5
View File
@@ -0,0 +1,5 @@
root: ./docs
structure:
readme: README.md
summary: SUMMARY.md
-101
View File
@@ -1,101 +0,0 @@
name: Bug report
description: Report an issue or unexpected behavior in pyfragment.
labels:
- 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:
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: 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: 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: Current behavior
description: Describe what is actually happening.
placeholder: e.g. ParseError is raised with status 400.
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: Code example
description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable.
placeholder: |
import asyncio
from pyfragment import FragmentClient
async def main():
client = FragmentClient(...)
result = await client.purchase_stars("@username", amount=100)
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.
-5
View File
@@ -1,5 +0,0 @@
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.
-51
View File
@@ -1,51 +0,0 @@
name: Feature request
description: Suggest an improvement or new feature for pyfragment.
labels:
- enhancement
body:
- 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: 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: 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.
-34
View File
@@ -1,34 +0,0 @@
# Description
Please include a summary of the change and which issue is fixed.
Include relevant motivation and context.
Fixes # (issue)
## Type of change
- [ ] Documentation (typos, examples, or any docs update)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] This change requires a documentation update
## How has this been tested?
Describe the tests you ran to verify the change and list any relevant details.
- [ ] Existing tests pass (`pytest`)
- [ ] New tests added for this change
**Test configuration:**
* OS:
* Python version:
* pyfragment version:
## Checklist
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have updated documentation where necessary
- [ ] I have added tests that prove my fix or feature works
- [ ] All new and existing tests pass locally
-19
View File
@@ -1,19 +0,0 @@
version: 2
updates:
- 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"
-49
View File
@@ -1,49 +0,0 @@
name: CI
on:
push:
branches: [ "**" ]
pull_request:
branches: [ "**" ]
jobs:
lint:
name: Lint & Format
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.10"
- run: pip install ".[dev]"
- run: ruff check . && ruff format --check . && mypy pyfragment
test:
name: Tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
strategy:
fail-fast: false
matrix:
python-version: [ "3.10", "3.11", "3.12" ] # 3.13, 3.14 are not supported by some dependencies yet
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install package and dev dependencies
run: pip install ".[dev]"
- name: Run tests
run: pytest
+23
View File
@@ -0,0 +1,23 @@
name: Docs Branch Check
on:
push:
branches: [docs]
pull_request:
branches: [docs]
jobs:
docs-tree:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7.0.1
- name: Ensure docs structure exists
run: |
test -f docs/README.md
test -f docs/SUMMARY.md
test -d docs/getting-started
test -d docs/client
test -d docs/reference
test -d docs/advanced
-117
View File
@@ -1,117 +0,0 @@
name: Publish
on:
workflow_run:
workflows: [ "CI" ]
types: [ completed ]
branches: [ master ]
jobs:
version-check:
name: Version Check
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
outputs:
version: ${{ steps.version.outputs.value }}
is-new: ${{ steps.tag.outputs.is-new }}
steps:
- 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: 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
needs: version-check
if: needs.version-check.outputs.is-new == 'true'
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v8.1.0
- run: uv build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/*
publish:
name: Publish to PyPI
needs: [ version-check, build ]
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
environment:
name: pypi
url: https://pypi.org/project/pyfragment/
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
name: dist
path: dist
- uses: pypa/gh-action-pypi-publish@release/v1
release:
name: GitHub Release
needs: [ version-check, build ]
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/download-artifact@v8
with:
name: dist
path: dist
- name: Extract latest changelog entry
id: changelog
run: |
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$body" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- uses: softprops/action-gh-release@v3
with:
tag_name: v${{ needs.version-check.outputs.version }}
name: v${{ needs.version-check.outputs.version }}
files: dist/*
body: ${{ steps.changelog.outputs.body }}
make_latest: true
-38
View File
@@ -1,39 +1 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
# Virtual environments
.venv/
venv/
# IDE
.idea/
.vscode/
# Logs
logs/
*.log
# Environment variables
.env
# System files
.DS_Store .DS_Store
Thumbs.db
# Testing & tooling artifacts
.hypothesis/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
systests/
# Build & distribution
dist/
build/
*.egg-info/
-126
View File
@@ -1,126 +0,0 @@
# Changelog
All notable changes to pyfragment are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MICRO`).
---
## [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
- `get_cookies_from_browser(browser)` — extract Fragment session cookies directly from an installed browser (Chrome, Firefox, Edge, Brave, Arc, Opera, Safari, and more); no browser extension or manual copy-paste required
```python
from pyfragment.utils import get_cookies_from_browser
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
client = FragmentClient(seed="...", api_key="...", cookies=result.cookies)
print(result.expires) # ISO 8601 expiry of stel_ssid, or None for session cookies
```
- `CookieResult` — return type of `get_cookies_from_browser()`; exposes `.cookies` (`dict[str, str]`) and `.expires` (ISO 8601 string or `None`)
### Changed
- `DEVICE` Tonkeeper fingerprint updated: `appVersion` → `26.04.0`
- `tonutils` upgraded to **2.1.0**
- Minimum Python version lowered to **3.10** (previously 3.12)
---
## [2026.1.0] — 2026-03-25
### 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
- All page URL constants built from `FRAGMENT_BASE_URL`;
- `TransactionError` includes an SSL hint; `DUPLICATE_SEQNO` variant auto-retried up to 2 times (2 s apart)
- Error messages rewritten: "what happened → why → what to do"
---
## [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__`
---
## [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)
- `purchase_stars(username, amount)` — send Telegram Stars to any user (501,000,000)
- `topup_ton(username, amount)` — top up TON Ads balance (11,000,000,000 TON)
- `get_wallet()` — fetch wallet address and balance
- Support for TON wallet versions `V4R2` and `V5R1`
- Structured exception hierarchy (`FragmentError`, `ConfigurationError`, `CookieError`, etc.)
- `py.typed` marker — full PEP 561 typing support for type-checkers
- `__repr__` on all result types for readable debug output
[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
[2026.0.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.1
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 bohd4nx
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+4 -141
View File
@@ -1,143 +1,6 @@
<div align="center"> # pyfragment docs branch
<img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="Fragment Logo" width="120" height="120" style="border-radius: 24px;">
<h1 style="margin-top: 24px;">Fragment API</h1> This branch is dedicated to GitBook content.
<p style="font-size: 18px; margin-bottom: 24px;"> - Main docs source: docs/
<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> - Navigation: docs/SUMMARY.md
</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/)
[![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)
[Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
</div>
> **Disclaimer:** This project is not affiliated with, endorsed by, or in any way officially connected with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
---
## Installation
```bash
pip install pyfragment
```
To install the latest unreleased changes from the `dev` branch:
```bash
pip install git+https://github.com/bohd4nx/pyfragment.git@dev
```
Requires Python 3.10+.
---
## Configuration
| Parameter | Type | Default | Description |
| ---------------- | ------------- | -------- | -------------------------------------------------------- |
| `seed` | `str` | — | 24-word TON wallet mnemonic |
| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
| `cookies` | `dict \| str` | — | Fragment session cookies |
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
---
## Credentials
**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways:
- **Automatically** (recommended) — use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed:
```python
from pyfragment.utils import get_cookies_from_browser
result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ...
# result.cookies — dict[str, str] to pass to FragmentClient
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
```
- **Manually** — install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or JSON string.
Refresh when you get authentication errors.
**Tonapi key** — generate at [tonconsole.com](https://tonconsole.com).
**Seed phrase** — 24-word mnemonic from your TON wallet (Tonkeeper → Settings → Backup). Never share it.
---
## Usage
```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
)
async def main() -> None:
async with FragmentClient(
seed="word1 word2 ... word24", # 24-word TON wallet mnemonic
api_key="YOUR_TONAPI_KEY", # from tonconsole.com
cookies={
"stel_ssid": "...",
"stel_dt": "...",
"stel_token": "...",
"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}")
# 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}")
# 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}")
except UserNotFoundError:
print(f"User was not found on fragment.com — check the username and try again.")
except WalletError as e:
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
except CookieError:
print("Authentication failed — session cookies are missing or expired. Refresh them and retry.")
except TransactionError as e:
print(f"Transaction failed to broadcast on-chain: {e}")
except ConfigurationError as e:
print(f"Invalid argument: {e}")
except FragmentAPIError as e:
print(f"Unexpected response from Fragment API: {e}")
except FragmentError as e:
# catch-all for any other pyfragment error
print(f"Unexpected error: {e}")
asyncio.run(main())
```
---
<div align="center">
### Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx)
**Star ⭐ this repo if you found it useful!**
</div>
+48
View File
@@ -0,0 +1,48 @@
# Overview
`pyfragment` is an async Python client for [Fragment](https://fragment.com).
If you are integrating Fragment into a bot or backend, this docs set is meant to be practical, not theoretical.
**Recommended reading order:**
1. Install the package
2. Configure `FragmentClient`
3. Set up credentials and cookies
4. Run the quick start
5. Move to feature-specific flows
## Who this is for
- Python developers integrating Fragment into bots, services, and automation.
- Teams that need predictable typed results and explicit error behavior.
**Important:** this library is not affiliated with Fragment or Telegram.
## Where to begin
1. [Installation](getting-started/installation.md)
2. [Library and Configuration](getting-started/configuration.md)
3. [Credentials and Cookies](getting-started/credentials-and-cookies.md)
4. [Quick Start](getting-started/quickstart.md)
## Feature entry points
- Stars: [Purchase](client/stars/purchase.md), [Giveaway](client/stars/giveaway.md)
- Premium: [Purchase](client/premium/purchase.md), [Giveaway](client/premium/giveaway.md)
- Marketplace: [Overview](client/marketplace/overview.md), Ads: [Overview](client/ads/overview.md)
- Numbers: [Anonymous Numbers](client/anonymous-numbers/overview.md)
- Utility operations: [Raw API Calls](client/raw-call.md)
## Additional references
- [Error Handling](reference/errors.md)
- [Result Models](reference/models.md)
- [Literal Types](reference/literals.md)
- [Troubleshooting](advanced/troubleshooting.md)
## Live examples
**Up-to-date runnable examples live in the main repository:**
- https://github.com/bohd4nx/pyfragment/tree/master/examples
+36
View File
@@ -0,0 +1,36 @@
- [Overview](README.md)
- Setup Guide
- [Installation](getting-started/installation.md)
- [Library and Configuration](getting-started/configuration.md)
- [Credentials and Cookies](getting-started/credentials-and-cookies.md)
- [Quick Start](getting-started/quickstart.md)
- [Error Handling](reference/errors.md)
- API Guides
- [Overview](client/overview.md)
- Stars
- [Purchase](client/stars/purchase.md)
- [Giveaway](client/stars/giveaway.md)
- Premium
- [Purchase](client/premium/purchase.md)
- [Giveaway](client/premium/giveaway.md)
- Marketplace
- [Overview](client/marketplace/overview.md)
- [Search Usernames](client/marketplace/search-usernames.md)
- [Search Numbers](client/marketplace/search-numbers.md)
- [Search Gifts](client/marketplace/search-gifts.md)
- Ads
- [Overview](client/ads/overview.md)
- [Top Up GRAM](client/ads/topup-gram.md)
- [Recharge Ads](client/ads/recharge-ads.md)
- Anonymous Numbers
- [Overview](client/anonymous-numbers/overview.md)
- [Get Login Code](client/anonymous-numbers/get-login-code.md)
- [Toggle Login Codes](client/anonymous-numbers/toggle-login-codes.md)
- [Terminate Sessions](client/anonymous-numbers/terminate-sessions.md)
- [Raw API Calls](client/raw-call.md)
- Reference
- [Result Models](reference/models.md)
- [Literal Types](reference/literals.md)
- Advanced
- [Cookie Extraction Details](advanced/cookies.md)
- [Troubleshooting](advanced/troubleshooting.md)
+24
View File
@@ -0,0 +1,24 @@
# Cookie Extraction Details
`get_cookies_from_browser(browser)` reads Fragment cookies from local browser storage (via `rookiepy`).
This is the fastest way to start when you do not want manual cookie export.
Supported browsers are defined in constants and include:
- chrome, firefox, edge, brave,
- arc, opera, opera_gx,
- safari, vivaldi,
- chromium variants.
Validation includes:
- required key presence,
- non-empty values,
- optional expiration check for `stel_ssid`.
**If any required cookie is empty or missing, extraction is treated as failed.**
If extraction fails, `CookieError` is raised with actionable details.
Use [Credentials and Cookies](../getting-started/credentials-and-cookies.md) for setup-first instructions.
+59
View File
@@ -0,0 +1,59 @@
# Troubleshooting
When something breaks, start here. Most issues are caused by cookies, session state, or wallet balance.
## Auth/session errors
Symptoms:
- Fragment page hash cannot be extracted,
- bad status loading Fragment pages,
- missing request IDs.
Actions:
- re-login on fragment.com,
- refresh cookies,
- ensure all `stel_*` keys are present.
- verify constructor payload in [Library and Configuration](../getting-started/configuration.md).
**Re-login + fresh cookies solves the majority of auth errors.**
## Cookie extraction errors
Symptoms:
- browser not supported,
- cannot read browser profile,
- required cookies not found.
Actions:
- install `pyfragment[browser]`,
- close locked browser profiles,
- use manual cookies if needed.
## Balance/transaction failures
Symptoms:
- low TON/USDT balance errors,
- broadcast failures,
- duplicate seqno retries.
Actions:
- keep GRAM (ex TON) reserve for fees,
- ensure USDT is on the **Fragment-linked wallet**,
- retry after short delay when seqno collisions happen.
- check operation constraints in Stars/Premium/Ads method pages.
## SSL-related broadcast failures
If you get SSL-related errors during **TON transaction broadcast** (not Fragment page loading — those use curl_cffi with bundled SSL):
```bash
pip install --upgrade certifi
```
On macOS, also run Python's `Install Certificates.command` if needed.
+16
View File
@@ -0,0 +1,16 @@
# Ads Overview
Ads flow is split into two methods:
- [Top Up GRAM](topup-gram.md)
- [Recharge Ads](recharge-ads.md)
Use the first method to send GRAM (ex TON) to a Telegram user.
Use the second method to fund your own Telegram Ads account.
## Common errors
- `ConfigurationError`
- `UserNotFoundError` (for recipient/account issues)
- `WalletError`
- `VerificationError`
+30
View File
@@ -0,0 +1,30 @@
# Recharge Ads
Use this method to add funds to your Telegram Ads account.
## Method
```python
await client.recharge_ads(
account: str,
amount: int,
) -> AdsRechargeResult
```
## Parameters
- `account`: channel or bot username linked to your ads account
- `amount`: integer from `1` to `1_000_000_000`
**Important:** `amount` must be an integer in the allowed range.
## Return
- `AdsRechargeResult(transaction_id, amount)`
## Example
```python
result: AdsRechargeResult = await client.recharge_ads("@mychannel", amount=50)
print(result.transaction_id)
```
+38
View File
@@ -0,0 +1,38 @@
# Top Up GRAM
Use this method to send GRAM (ex TON) to a user's Telegram balance.
## Method
```python
await client.topup_gram(
username: str,
amount: int,
show_sender: bool = True,
) -> AdsTopupResult
```
## Parameters
- `username`: recipient Telegram username — `@username`, `username`, or `https://t.me/username`
- `amount`: integer from `1` to `1_000_000_000`
- `show_sender`: controls sender visibility
**`amount` must be an integer in the allowed range.**
## Return
- `AdsTopupResult(transaction_id, username, amount)`
## Typical errors
- `ConfigurationError`: invalid amount
- `UserNotFoundError`: recipient not found on Fragment
- `WalletError`: insufficient GRAM (ex TON) balance
## Example
```python
result: AdsTopupResult = await client.topup_gram("@username", amount=10, show_sender=True)
print(result.transaction_id)
```
@@ -0,0 +1,26 @@
# Get Login Code
Use this method to fetch a pending login code for an anonymous number.
## Method
```python
await client.get_login_code(number: str) -> LoginCodeResult
```
## Parameters
- `number`: anonymous number (with or without leading `+`)
## Return
- `number`
- `code` (`None` if no pending code)
- `active_sessions`
## Example
```python
result: LoginCodeResult = await client.get_login_code("+1234567890")
print(result.code)
```
+16
View File
@@ -0,0 +1,16 @@
# Anonymous Numbers Overview
These methods help you manage login behavior and active sessions for anonymous numbers owned by your account.
Available methods:
- [Get Login Code](get-login-code.md)
- [Toggle Login Codes](toggle-login-codes.md)
- [Terminate Sessions](terminate-sessions.md)
## Common errors
- `AnonymousNumberError.NOT_OWNED`
- `AnonymousNumberError.TERMINATE_FAILED`
**If a number is not owned by your account, requests will fail.**
@@ -0,0 +1,25 @@
# Terminate Sessions
Use this method to terminate active sessions for an anonymous number.
## Method
```python
await client.terminate_sessions(number: str) -> TerminateSessionsResult
```
## Parameters
- `number`: anonymous number (with or without leading `+`)
## Return
- `number`
- `message`
## Example
```python
result: TerminateSessionsResult = await client.terminate_sessions("+1234567890")
print(result.message)
```
@@ -0,0 +1,24 @@
# Toggle Login Codes
Use this method to allow or block login code delivery.
## Method
```python
await client.toggle_login_codes(number: str, can_receive: bool) -> None
```
## Parameters
- `number`: anonymous number (with or without leading `+`)
- `can_receive`: `True` to allow codes, `False` to block codes
## Return
- `None`
## Example
```python
await client.toggle_login_codes("+1234567890", can_receive=False)
```
+31
View File
@@ -0,0 +1,31 @@
# Marketplace Overview
Marketplace methods are exposed directly on `FragmentClient` and via `client.marketplace` service.
If you only need one thing: pick the method by asset type (username, number, gift), then paginate until `next_offset_id` or `next_offset` becomes `None`.
Available methods:
- [Search Usernames](search-usernames.md)
- [Search Numbers](search-numbers.md)
- [Search Gifts](search-gifts.md)
## Shared behavior
- All methods are async.
- All methods call Fragment `searchAuctions` under the hood.
- `sort` and `filter` are optional passthrough strings.
**These values are passed to Fragment as-is.** If Fragment changes accepted values, behavior can change too.
Common values used by Fragment pages:
- `sort`: `price_desc`, `price_asc`, `listed`, `ending`
- `filter`: empty string, `auction`, `sale`, `sold`
## Pagination model
- Usernames and Numbers return `next_offset_id` (string)
- Gifts return `next_offset` (integer)
Use these fields to request next pages.
+87
View File
@@ -0,0 +1,87 @@
# Search Gifts
This endpoint is the most flexible marketplace search and supports collection, traits, and pagination.
## Method
```python
await client.search_gifts(
query: str = "",
collection: str | None = None,
sort: str | None = None,
filter: str | None = None,
view: str | None = None,
attr: dict[str, list[str]] | None = None,
offset: int | None = None,
) -> GiftsResult
```
## Parameters
- `query`: search text (empty string for broad listing)
- `collection`: collection slug (for example `plushpepe`, `swisswatch`)
- `sort`: optional sort key passed to Fragment
- `filter`: optional listing filter passed to Fragment
- `view`: optional UI/view mode passed to Fragment
- `attr`: optional trait filters where key is trait name and value is list of allowed values
- `offset`: page offset for next page
**`attr` is ideal for narrowing results by visual or rarity traits.**
## Sorting values
Common values accepted by Fragment:
- `price_desc`
- `price_asc`
- `listed`
- `ending`
## Filter values
Common values accepted by Fragment:
- empty string
- `auction`
- `sale`
- `sold`
## Attribute filter format
`attr` is encoded into request fields in this form:
- `attr[trait_name] = ["value1", "value2"]`
Example:
```python
attr={
"model": ["gold", "silver"],
"rarity": ["rare"],
}
```
In requests, each trait is sent as `attr[trait]` with a list of values.
## Return type
`GiftsResult` contains:
- `items: list[dict[str, Any]]`
- `next_offset: int | None`
## Pagination
If `next_offset` is not `None`, pass it back as `offset` to load the next page.
## Example
```python
result: GiftsResult = await client.search_gifts(
query="",
collection="plushpepe",
sort="price_desc",
filter="auction",
)
print(len(result.items), result.next_offset)
```
+61
View File
@@ -0,0 +1,61 @@
# Search Numbers
Use this endpoint to search anonymous Telegram number listings.
## Method
```python
await client.search_numbers(
query: str = "",
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> NumbersResult
```
## Parameters
- `query`: digits or text to match number listings
- `sort`: optional sort key passed to Fragment
- `filter`: optional listing filter passed to Fragment
- `offset_id`: page cursor for next page
`query` can be partial digits (for example `"888"`) when you need pattern-based discovery.
## Sorting values
Common values accepted by Fragment:
- `price_desc`
- `price_asc`
- `listed`
- `ending`
## Filter values
Common values accepted by Fragment:
- empty string
- `auction`
- `sale`
- `sold`
## Return type
`NumbersResult` contains:
- `items: list[dict[str, Any]]`
- `next_offset_id: str | None`
## Pagination
If `next_offset_id` is not `None`, pass it back as `offset_id` to load the next page.
Keep requesting pages until `next_offset_id` becomes `None`.
## Example
```python
result: NumbersResult = await client.search_numbers("888", sort="price_asc", filter="sale")
print(len(result.items), result.next_offset_id)
```
@@ -0,0 +1,61 @@
# Search Usernames
Use this endpoint to discover Telegram usernames listed on Fragment.
## Method
```python
await client.search_usernames(
query: str = "",
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> UsernamesResult
```
## Parameters
- `query`: search text (empty string means broad listing)
- `sort`: optional sort key passed to Fragment
- `filter`: optional listing filter passed to Fragment
- `offset_id`: page cursor for next page
For broad browsing, use empty `query` and set sorting only.
## Sorting values
Common values accepted by Fragment:
- `price_desc`
- `price_asc`
- `listed`
- `ending`
## Filter values
Common values accepted by Fragment:
- empty string
- `auction`
- `sale`
- `sold`
## Return type
`UsernamesResult` contains:
- `items: list[dict[str, Any]]`
- `next_offset_id: str | None`
## Pagination
If `next_offset_id` is not `None`, pass it back as `offset_id` to load the next page.
This is cursor pagination, so do not try to calculate offsets manually.
## Example
```python
result: UsernamesResult = await client.search_usernames("durov", sort="price_desc", filter="auction")
print(len(result.items), result.next_offset_id)
```
+43
View File
@@ -0,0 +1,43 @@
# Client Overview
`FragmentClient` is the main API surface.
You can call methods directly on the client or use grouped services.
Grouped service wrappers:
- `client.purchases`
- `client.giveaways`
- `client.ads`
- `client.anonymous_numbers`
- `client.marketplace`
- `client.tonapi`
Main async methods on `FragmentClient`:
- `purchase_stars(...)`
- `purchase_premium(...)`
- `giveaway_stars(...)`
- `giveaway_premium(...)`
- `topup_gram(...)`
- `recharge_ads(...)`
- `get_wallet()`
- `get_login_code(...)`
- `toggle_login_codes(...)`
- `terminate_sessions(...)`
- `search_usernames(...)`
- `search_numbers(...)`
- `search_gifts(...)`
- `call(...)`
All methods are async and should be used inside `async with FragmentClient(...) as client:`.
## Flow map
- Stars: [Purchase](stars/purchase.md), [Giveaway](stars/giveaway.md)
- Premium: [Purchase](premium/purchase.md), [Giveaway](premium/giveaway.md)
- Marketplace: [Overview](marketplace/overview.md), Ads: [Overview](ads/overview.md)
- Numbers: [Anonymous Numbers](anonymous-numbers/overview.md)
- Utility operations: [Raw API Calls](raw-call.md)
**If you are new to the library, start with Stars Purchase or Wallet read (`get_wallet`) first.**
+41
View File
@@ -0,0 +1,41 @@
# Premium Giveaway
Use this method to run a Telegram Premium giveaway for your channel.
## Method
```python
await client.giveaway_premium(
channel: str,
winners: int,
months: int = 3,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumGiveawayResult
```
## Parameters
- `channel`: accepts `@channel`, `channel`, or `https://t.me/channel`
- `winners`: integer from `1` to `24_000`
- `months`: one of `3`, `6`, `12`
- `payment_method`: `PaymentMethod.GRAM` (default), `PaymentMethod.USDT_GRAM`, or any other `PaymentMethod` value
**`winners` must be a positive integer, and large values can increase total cost significantly.**
## Return
- `PremiumGiveawayResult(transaction_id, channel, winners, amount)`
## Typical errors
- `ConfigurationError`
- `UserNotFoundError`
- `WalletError`
- `VerificationError`
## Example
```python
result: PremiumGiveawayResult = await client.giveaway_premium("@channel", winners=100, months=3)
print(result.amount)
```
+41
View File
@@ -0,0 +1,41 @@
# Premium Purchase
Use this method to gift Telegram Premium to a specific user.
## Method
```python
await client.purchase_premium(
username: str,
months: int,
show_sender: bool = True,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> PremiumResult
```
## Parameters
- `username`: accepts `@username`, `username`, or `https://t.me/username`
- `months`: one of `3`, `6`, `12`
- `show_sender`: controls sender visibility on recipient side
- `payment_method`: `PaymentMethod.GRAM` (default), `PaymentMethod.USDT_GRAM`, or any other `PaymentMethod` value
**`months` only supports `3`, `6`, or `12`.**
## Return
- `PremiumResult(transaction_id, username, amount)`
## Typical errors
- `ConfigurationError`
- `UserNotFoundError`
- `WalletError`
- `VerificationError`
## Example
```python
result: PremiumResult = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM)
print(result.transaction_id)
```
+42
View File
@@ -0,0 +1,42 @@
# Raw API Calls
Use `client.call()` when you need a Fragment API method that does not yet have a dedicated wrapper.
```python
result = await client.call(
"searchPremiumGiftRecipient",
{"query": "@username", "months": 3},
page_url="https://fragment.com/premium/gift",
)
```
Signature:
```python
await client.call(
method: str,
data: dict[str, Any] | None = None,
*,
page_url: str = "https://fragment.com",
) -> dict[str, Any]
```
## Parameters
- `method`: Fragment API method name
- `data`: optional request payload as dictionary
- `page_url`: page URL used for referer/hash context (defaults to `https://fragment.com`)
## Return
- `dict[str, Any]`: raw Fragment API response
Use this carefully:
- request/response shape is Fragment-defined,
- undocumented methods can change without notice,
- you are responsible for validating returned fields.
## Recommended approach
Use dedicated wrappers first, and fallback to `call()` only for missing API surface.
+41
View File
@@ -0,0 +1,41 @@
# Stars Giveaway
Use this method to run a Stars giveaway for a channel audience.
## Method
```python
await client.giveaway_stars(
channel: str,
winners: int,
amount: int,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsGiveawayResult
```
## Parameters
- `channel`: accepts `@channel`, `channel`, or `https://t.me/channel`
- `winners`: integer from `1` to `15`
- `amount`: integer from `500` to `1_000_000` (per winner)
- `payment_method`: `PaymentMethod.GRAM` (default), `PaymentMethod.USDT_GRAM`, or any other `PaymentMethod` value
**Each winner receives the full `amount` value.**
## Return
- `StarsGiveawayResult(transaction_id, channel, winners, amount)`
## Typical errors
- `ConfigurationError`
- `UserNotFoundError`
- `WalletError`
- `VerificationError`
## Example
```python
result: StarsGiveawayResult = await client.giveaway_stars("@channel", winners=3, amount=1000)
print(result.transaction_id)
```
+41
View File
@@ -0,0 +1,41 @@
# Stars Purchase
Use this method to send Telegram Stars directly to a user.
## Method
```python
await client.purchase_stars(
username: str,
amount: int,
show_sender: bool = True,
payment_method: PaymentMethod = PaymentMethod.GRAM,
) -> StarsResult
```
## Parameters
- `username`: accepts `@username`, `username`, or `https://t.me/username`
- `amount`: integer from `50` to `10_000_000`
- `show_sender`: controls sender visibility on recipient side
- `payment_method`: `PaymentMethod.GRAM` (default), `PaymentMethod.USDT_GRAM`, or any other `PaymentMethod` value
**Amount must be between `50` and `10_000_000`.**
## Return
- `StarsResult(transaction_id, username, amount)`
## Typical errors
- `ConfigurationError`: invalid amount or payment method
- `UserNotFoundError`: target user not found
- `WalletError`: insufficient balance or wallet-side issue
- `VerificationError`: verification/KYC required for operation
## Example
```python
result: StarsResult = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.GRAM)
print(result.amount)
```
+78
View File
@@ -0,0 +1,78 @@
# Library and Configuration
Main entry point of the library is `FragmentClient`.
```python
FragmentClient(
seed: str,
api_key: str,
cookies: dict[str, Any] | str,
wallet_version: str = "V5R1",
api_provider: str = "tonapi",
timeout: float = 30.0,
)
```
## Parameters
- `seed`: wallet mnemonic (**12 or 24 words**)
- `api_key`: API key — from [tonconsole.com](https://tonconsole.com) (tonapi) or [@toncenter](https://t.me/toncenter)
- `cookies`: Fragment cookies as a dictionary or JSON string
- `wallet_version`: `"V4R2"`, `"V5R1"`, `"HighloadV2"`, or `"HighloadV3R1"`
- `api_provider`: blockchain API provider — `"tonapi"` (default) or `"toncenter"`
- `timeout`: request timeout in seconds
**If `api_key` or cookies are missing, initialization fails immediately.**
## Required cookies
- `stel_ssid`
- `stel_dt`
- `stel_token`
- `stel_ton_token`
## Minimal initialization pattern
```python
from pyfragment import FragmentClient
async with FragmentClient(
seed="word1 word2 ... word24",
api_key="YOUR_API_KEY",
cookies={
"stel_ssid": "...",
"stel_dt": "...",
"stel_token": "...",
"stel_ton_token": "...",
},
) as client:
wallet = await client.get_wallet()
```
## Switching API provider
By default, the library uses [tonconsole.com](https://tonconsole.com) (tonapi). To use [toncenter](https://t.me/toncenter) instead, pass `api_provider="toncenter"`:
```python
async with FragmentClient(
seed="...",
api_key="YOUR_TONCENTER_API_KEY",
cookies={...},
api_provider="toncenter",
) as client:
...
```
Both providers work identically — the correct `tonutils` client is selected automatically based on `api_provider`.
## Validation behavior
At initialization, library validates:
- seed format,
- cookie shape and required keys,
- supported wallet version,
- supported API provider,
- parseability of cookie JSON strings.
Constructor-level issues are raised as `ConfigurationError` or `CookieError`.
@@ -0,0 +1,56 @@
# Credentials and Cookies
This page covers the three things you need before making real requests: Tonapi key, wallet seed, and Fragment cookies.
## Tonapi key
Generate an API key at https://tonconsole.com.
## Seed phrase
Use your GRAM (ex TON) wallet mnemonic.
- **Keep it private.**
- **Never log it or commit it to git.**
## Fragment cookies
You must be logged in to Fragment.
### Option 1: automatic extraction
```python
from pyfragment import get_cookies_from_browser
cookie_result = get_cookies_from_browser("chrome")
cookies = cookie_result.cookies
```
`cookie_result` is `CookieResult`:
- `cookies`: `dict[str, str]`
- `expires`: ISO string or `None`
### Option 2: manual export
Export the four required Fragment cookies and pass them directly as dict or JSON string.
Required keys:
- `stel_ssid`
- `stel_dt`
- `stel_token`
- `stel_ton_token`
## Common auth failures
- expired session cookies,
- not logged in on fragment.com,
- missing `stel_*` keys,
- stale cookies from another browser/profile.
When this happens, re-login on fragment.com and refresh cookies first. It solves most auth issues.
## Next step
Proceed to [Quick Start](quickstart.md).
+35
View File
@@ -0,0 +1,35 @@
# Installation
You can be up and running in under a minute.
## Requirements
- Python 3.11 3.14
## Install from PyPI
```bash
pip install pyfragment
```
## Install latest dev branch
```bash
pip install git+https://github.com/bohd4nx/pyfragment.git@dev
```
## Optional browser cookie extraction support
If you want automatic cookie extraction from local browser profiles:
```bash
pip install "pyfragment[browser]"
```
This installs `rookiepy`, used by `get_cookies_from_browser()`.
**Use this extra if you do not want to copy cookies manually.**
## Next step
After installation, continue with [Library and Configuration](configuration.md).
+48
View File
@@ -0,0 +1,48 @@
# Quick Start
Use this minimal example to verify that your credentials, cookies, and wallet setup are correct.
```python
import asyncio
from pyfragment import FragmentClient
from pyfragment.enums import PaymentMethod
async def main() -> None:
async with FragmentClient(
seed="word1 word2 ... word24",
api_key="YOUR_API_KEY", # tonconsole.com (tonapi, default) or t.me/toncenter
cookies={
"stel_ssid": "...",
"stel_dt": "...",
"stel_token": "...",
"stel_ton_token": "...",
},
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client:
wallet = await client.get_wallet()
print("GRAM: %s | USDT: %s" % (wallet.gram_balance, wallet.usdt_balance))
recipient = "https://t.me/username" # also: @username, username
stars = await client.purchase_stars(recipient, amount=500, payment_method=PaymentMethod.USDT_GRAM)
print("Sent %s Stars to %s | tx: %s" % (stars.amount, stars.username, stars.transaction_id))
premium = await client.purchase_premium(recipient, months=6, payment_method=PaymentMethod.GRAM)
print("Sent Premium %sm to %s | tx: %s" % (premium.amount, premium.username, premium.transaction_id))
asyncio.run(main())
```
If this script returns wallet data, your setup is healthy.
Then move to feature pages:
- Stars: [Purchase](client/stars/purchase.md), [Giveaway](client/stars/giveaway.md)
- Premium: [Purchase](client/premium/purchase.md), [Giveaway](client/premium/giveaway.md)
- Marketplace: [Overview](client/marketplace/overview.md), Ads: [Overview](client/ads/overview.md)
- Numbers: [Anonymous Numbers](client/anonymous-numbers/overview.md)
- Utility operations: [Raw API Calls](client/raw-call.md)
+56
View File
@@ -0,0 +1,56 @@
# Error Handling
Good error handling is the difference between a stable integration and random production failures.
## Exception hierarchy
- `FragmentError`
- `ClientError`
- `ConfigurationError`
- `CookieError`
- `FragmentAPIError`
- `FragmentPageError`
- `UserNotFoundError`
- `AlreadySubscribedError`
- `AnonymousNumberError`
- `TransactionError`
- `ParseError`
- `VerificationError`
- `OperationError`
- `WalletError`
- `UnexpectedError`
## Recommended handling pattern
```python
from pyfragment import ConfigurationError, FragmentError, UserNotFoundError, WalletError
try:
result = await client.purchase_stars("@username", amount=500)
except UserNotFoundError:
# recipient does not exist on Fragment
...
except WalletError:
# insufficient balance or wallet-side issue
...
except ConfigurationError:
# invalid local input
...
except FragmentError:
# any other library-level failure
...
```
**Catch specific errors first, then fallback to `FragmentError`.**
## Method-to-error mapping
- Stars purchase: `ConfigurationError`, `UserNotFoundError`, `WalletError`, `VerificationError`
- Premium purchase: `ConfigurationError`, `UserNotFoundError`, `AlreadySubscribedError`, `WalletError`, `VerificationError`
- Stars/Premium giveaway: `ConfigurationError`, `UserNotFoundError`, `WalletError`, `VerificationError`
- Ads operations: `ConfigurationError`, `UserNotFoundError`, `WalletError`, `VerificationError`
- Cookies/auth setup: `CookieError`, `ConfigurationError`, `FragmentPageError`
## Canonical messages
See `pyfragment/exceptions.py` for source-of-truth message templates.
+54
View File
@@ -0,0 +1,54 @@
# Literal Types
These literals describe accepted string values for key method parameters.
## ApiProvider
```python
from pyfragment.enums import ApiProvider
ApiProvider.TONAPI # tonconsole.com — default
ApiProvider.TONCENTER # t.me/toncenter
```
Pass as string to `FragmentClient(api_provider=...)`:
```python
FragmentClient(..., api_provider="tonapi") # default
FragmentClient(..., api_provider="toncenter")
```
## PaymentMethod
```python
from pyfragment.enums import PaymentMethod
PaymentMethod.GRAM # GRAM (ex TON) — default
PaymentMethod.USDT_GRAM # USDT on GRAM (ex TON)
PaymentMethod.USDT_ETH # USDT on Ethereum
PaymentMethod.USDT_POL # USDT on Polygon
PaymentMethod.USDC_ETH # USDC on Ethereum
PaymentMethod.USDC_BASE # USDC on Base
PaymentMethod.USDC_POL # USDC on Polygon
```
## WalletVersion
```python
from pyfragment.enums import WalletVersion
WalletVersion.V5R1 # default
WalletVersion.V4R2
WalletVersion.HighloadV2
WalletVersion.HighloadV3R1
```
All enums are exported from both `pyfragment` (top-level) and `pyfragment.enums`.
## Usage notes
- Use `ApiProvider` when configuring the blockchain API provider in `FragmentClient`.
- Use `PaymentMethod` for purchase and giveaway operations.
- Use `WalletVersion` when configuring `FragmentClient`.
**Passing unsupported values raises `ConfigurationError`.**
+47
View File
@@ -0,0 +1,47 @@
# Result Models
Every high-level method returns a typed model, so you can rely on predictable fields instead of raw payload parsing.
Exported result models:
- `CookieResult(cookies, expires)`
- `StarsResult(transaction_id, username, amount)`
- `PremiumResult(transaction_id, username, amount)`
- `AdsTopupResult(transaction_id, username, amount)`
- `AdsRechargeResult(transaction_id, amount)`
- `StarsGiveawayResult(transaction_id, channel, winners, amount)`
- `PremiumGiveawayResult(transaction_id, channel, winners, amount)`
- `WalletInfo(address, state, gram_balance, usdt_balance)`
- `LoginCodeResult(number, code, active_sessions)`
- `TerminateSessionsResult(number, message)`
- `UsernamesResult(items, next_offset_id)`
- `NumbersResult(items, next_offset_id)`
- `GiftsResult(items, next_offset)`
Most high-level methods return one of these dataclasses.
## Where they are used
- `purchase_stars()`: `StarsResult`
- `purchase_premium()`: `PremiumResult`
- `giveaway_stars()`: `StarsGiveawayResult`
- `giveaway_premium()`: `PremiumGiveawayResult`
- `topup_gram()`: `AdsTopupResult`
- `recharge_ads()`: `AdsRechargeResult`
- `get_wallet()`: `WalletInfo`
- `get_login_code()`: `LoginCodeResult`
- `terminate_sessions()`: `TerminateSessionsResult`
- `search_usernames()`: `UsernamesResult`
- `search_numbers()`: `NumbersResult`
- `search_gifts()`: `GiftsResult`
## Methods without dataclass return
- `toggle_login_codes()`: returns `None`
- `call()`: returns `dict[str, Any]` (raw Fragment API response)
## Cookie helper
`CookieResult` is returned by `get_cookies_from_browser()`, not by `FragmentClient` methods.
**Use these models directly in your app layer and avoid passing raw dictionaries around.**
-48
View File
@@ -1,48 +0,0 @@
"""
Example: search the Fragment gifts marketplace.
collection filters by gift type slug (e.g. "plushpepe", "swisswatch").
sort can be "price_desc", "price_asc", "listed", or "ending".
filter can be "", "auction", "sale", or "sold".
Use next_offset for pagination.
"""
import asyncio
import json
from pyfragment import FragmentClient, GiftsResult
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
QUERY = "" # search text — or omit for all
COLLECTION = "plushpepe" # gift collection slug — or omit for all
SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit
FILTER = "" # "", "auction", "sale", "sold" — or omit
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):")
print(json.dumps(result.items, indent=2))
if result.next_offset:
print(f"\nMore results available — next page offset: {result.next_offset}")
if __name__ == "__main__":
asyncio.run(main())
-46
View File
@@ -1,46 +0,0 @@
"""
Example: search the Fragment marketplace for anonymous Telegram numbers.
sort can be "price_desc", "price_asc", "listed", or "ending".
filter can be "", "auction", "sale", or "sold".
Use next_offset_id for pagination.
"""
import asyncio
import json
from pyfragment import FragmentClient, NumbersResult
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
QUERY = "888" # search term — or omit for all
SORT = "price_asc" # "price_desc", "price_asc", "listed", "ending" — or omit
FILTER = "" # "", "auction", "sale", "sold" — or omit
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):")
print(json.dumps(result.items, indent=2))
if result.next_offset_id:
print(f"\nMore results available — next page offset: {result.next_offset_id}")
if __name__ == "__main__":
asyncio.run(main())
-46
View File
@@ -1,46 +0,0 @@
"""
Example: search the Fragment marketplace for Telegram usernames.
sort can be "price_desc", "price_asc", "listed", or "ending".
filter can be "", "auction", "sale", or "sold".
Use next_offset_id for pagination.
"""
import asyncio
import json
from pyfragment import FragmentClient, UsernamesResult
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
QUERY = "durov" # search term
SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit
FILTER = "auction" # "", "auction", "sale", "sold" — or omit
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER)
print(f"Found {len(result.items)} result(s):")
print(json.dumps(result.items, indent=2))
if result.next_offset_id:
print(f"\nMore results available — next page offset: {result.next_offset_id}")
if __name__ == "__main__":
asyncio.run(main())
-43
View File
@@ -1,43 +0,0 @@
"""
Example: send a raw request to any Fragment API method.
Use client.call() when you need to access a method that is not yet
wrapped by the library, or to inspect raw API responses directly.
page_url is optional — only set it when the target method belongs to a
specific Fragment page (Fragment derives the API hash per page).
Defaults to the Fragment base URL.
"""
import asyncio
from pyfragment import FragmentClient
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
METHOD = "anyFragmentMethod" # replace with the actual method name
DATA = {"key": "value"} # replace with the actual request payload
PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page (optional)
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
result = await client.call(METHOD, DATA, page_url=PAGE_URL)
print(result)
if __name__ == "__main__":
asyncio.run(main())
-42
View File
@@ -1,42 +0,0 @@
"""
Example: fetch wallet address, state, and balance.
Cookies can be passed as a dict or as a JSON string.
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
"""
import asyncio
from pyfragment import FragmentClient
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
async def main() -> None:
async with FragmentClient(
seed=SEED,
api_key=API_KEY,
cookies=COOKIES,
wallet_version="V5R1", # or "V4R2"
) as client:
wallet = await client.get_wallet()
print(f"Address: {wallet.address}")
print(f"State: {wallet.state}")
print(f"Balance: {wallet.balance} TON")
if __name__ == "__main__":
asyncio.run(main())
-49
View File
@@ -1,49 +0,0 @@
"""
Example: manage an anonymous Telegram number — read login code and terminate sessions.
Use get_login_code() to fetch the current pending login code for your number.
Use toggle_login_codes() to enable or disable receiving codes.
Use terminate_sessions() to forcefully end all active Telegram sessions.
"""
import asyncio
from pyfragment import AnonymousNumberError, FragmentClient
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
NUMBER = "+88888888888"
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
# Fetch the latest login code
result = await client.get_login_code(NUMBER)
if result.code:
print(f"Login code for {result.number}: {result.code} ({result.active_sessions} active session(s))")
else:
print(f"No pending login code for {result.number} ({result.active_sessions} active session(s))")
# Terminate all active sessions
try:
terminated = await client.terminate_sessions(NUMBER)
print(f"Sessions terminated for {terminated.number}" + (f": {terminated.message}" if terminated.message else ""))
except AnonymousNumberError as e:
print(f"Could not terminate sessions: {e}")
if __name__ == "__main__":
asyncio.run(main())
-51
View File
@@ -1,51 +0,0 @@
"""
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.
"""
import asyncio
from pyfragment import (
AdsRechargeResult,
ConfigurationError,
FragmentClient,
WalletError,
)
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
AMOUNT = 10 # 11 000 000 000 TON
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
try:
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
except WalletError as e:
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
return
except ConfigurationError as e:
print(f"Invalid argument: {e}")
return
print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
if __name__ == "__main__":
asyncio.run(main())
-49
View File
@@ -1,49 +0,0 @@
"""
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.
"""
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
CHANNEL = "@channel"
WINNERS = 10 # 124 000
MONTHS = 3 # 3, 6 or 12
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)
except UserNotFoundError:
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
return
except ConfigurationError as e:
print(f"Invalid argument: {e}")
return
print(
f"Premium giveaway created for {result.channel}{result.winners} winner(s) × {result.amount} months each | tx: {result.transaction_id}"
)
if __name__ == "__main__":
asyncio.run(main())
-49
View File
@@ -1,49 +0,0 @@
"""
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.
"""
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
CHANNEL = "@channel"
WINNERS = 3 # 15
AMOUNT = 1000 # 5001 000 000 stars per winner
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)
except UserNotFoundError:
print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.")
return
except ConfigurationError as e:
print(f"Invalid argument: {e}")
return
print(
f"Stars giveaway created for {result.channel}{result.winners} winner(s) × {result.amount} stars each | tx: {result.transaction_id}"
)
if __name__ == "__main__":
asyncio.run(main())
-46
View File
@@ -1,46 +0,0 @@
"""
Example: purchase Telegram Premium for a user.
Supported durations: 3, 6, or 12 months.
Set show_sender=False to send anonymously.
"""
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
USERNAME = "@username"
MONTHS = 3 # 3, 6 or 12
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)
except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return
except ConfigurationError as e:
print(f"Invalid argument: {e}")
return
print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
if __name__ == "__main__":
asyncio.run(main())
-46
View File
@@ -1,46 +0,0 @@
"""
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.
"""
import asyncio
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
USERNAME = "@username"
AMOUNT = 500 # 501 000 000 stars
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)
except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return
except ConfigurationError as e:
print(f"Invalid argument: {e}")
return
print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
if __name__ == "__main__":
asyncio.run(main())
-56
View File
@@ -1,56 +0,0 @@
"""
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.
"""
import asyncio
from pyfragment import (
ConfigurationError,
FragmentClient,
UserNotFoundError,
WalletError,
)
from pyfragment.utils import get_cookies_from_browser # noqa: F401
SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY"
# Option A: extract cookies directly from your browser (no manual copy-paste needed)
# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ...
# Option B: provide cookies manually
COOKIES = {
"stel_ssid": "YOUR_STEL_SSID",
"stel_dt": "YOUR_STEL_DT",
"stel_token": "YOUR_STEL_TOKEN",
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
}
USERNAME = "@username"
AMOUNT = 10 # 11 000 000 000 TON
async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
try:
result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True)
except UserNotFoundError:
print(f"User {USERNAME} was not found on fragment.com — check the username and try again.")
return
except WalletError as e:
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
return
except ConfigurationError as e:
print(f"Invalid argument: {e}")
return
print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}")
if __name__ == "__main__":
asyncio.run(main())
-71
View File
@@ -1,71 +0,0 @@
# Copyright (c) 2026 bohd4nx
#
# This source code is licensed under the MIT License found in the
# LICENSE file in the root directory of this source tree.
from importlib.metadata import version
from pyfragment.client import FragmentClient
from pyfragment.types import (
AdsRechargeResult,
AdsTopupResult,
AnonymousNumberError,
ClientError,
ConfigurationError,
CookieError,
CookieResult,
FragmentAPIError,
FragmentError,
FragmentPageError,
GiftsResult,
LoginCodeResult,
NumbersResult,
OperationError,
ParseError,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
StarsResult,
TerminateSessionsResult,
TransactionError,
UnexpectedError,
UsernamesResult,
UserNotFoundError,
VerificationError,
WalletError,
WalletInfo,
)
__version__: str = version("pyfragment")
__all__ = [
"__version__",
"FragmentClient",
"AdsRechargeResult",
"AdsTopupResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult",
"UsernamesResult",
"WalletInfo",
"ClientError",
"ConfigurationError",
"CookieError",
"CookieResult",
"FragmentAPIError",
"FragmentError",
"FragmentPageError",
"AnonymousNumberError",
"OperationError",
"ParseError",
"TransactionError",
"UnexpectedError",
"UserNotFoundError",
"VerificationError",
"WalletError",
]
-372
View File
@@ -1,372 +0,0 @@
from __future__ import annotations
import json
from typing import Any, cast
import httpx
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
from pyfragment.methods.giveaway_premium import giveaway_premium
from pyfragment.methods.giveaway_stars import giveaway_stars
from pyfragment.methods.purchase_premium import purchase_premium
from pyfragment.methods.purchase_stars import purchase_stars
from pyfragment.methods.recharge_ads import recharge_ads
from pyfragment.methods.search_gifts import search_gifts
from pyfragment.methods.search_numbers import search_numbers
from pyfragment.methods.search_usernames import search_usernames
from pyfragment.methods.topup_ton import topup_ton
from pyfragment.types import (
AdsRechargeResult,
AdsTopupResult,
ConfigurationError,
CookieError,
GiftsResult,
LoginCodeResult,
NumbersResult,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
StarsResult,
TerminateSessionsResult,
UsernamesResult,
WalletInfo,
)
from pyfragment.types.constants import (
DEFAULT_TIMEOUT,
FRAGMENT_BASE_URL,
REQUIRED_COOKIE_KEYS,
SUPPORTED_WALLET_VERSIONS,
WalletVersion,
)
from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers
from pyfragment.utils.wallet import get_wallet_info
class FragmentClient:
"""
Client for the Fragment.com API.
.. note::
This library is not affiliated with, endorsed by, or in any way officially
connected with Fragment or Telegram.
Args:
seed: 24-word mnemonic phrase for the TON wallet.
api_key: Tonapi API key — get one at https://tonconsole.com.
cookies: Fragment session cookies as a dict or JSON string.
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
Raises:
ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
CookieError: If ``cookies`` cannot be parsed or are missing required keys.
Example::
async with FragmentClient(
seed="word1 word2 ...",
api_key="AAABBB...",
cookies={"stel_ssid": "...", "stel_dt": "...", ...},
) as client:
print(await client.get_wallet())
result = await client.purchase_premium("@username", months=6)
print(result.transaction_id)
"""
def __init__(
self,
seed: str,
api_key: str,
cookies: dict[str, Any] | str,
wallet_version: str = "V5R1",
timeout: float = DEFAULT_TIMEOUT,
) -> None:
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
if missing:
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
word_count = len(seed.split())
if word_count not in (12, 18, 24):
raise ConfigurationError(ConfigurationError.INVALID_MNEMONIC.format(count=word_count))
if len(api_key.strip()) < 68:
raise ConfigurationError(ConfigurationError.INVALID_API_KEY.format(length=len(api_key.strip())))
if isinstance(cookies, str):
try:
cookies = json.loads(cookies)
except Exception as exc:
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cast(dict[str, Any], cookies).get(k, "")).strip()]
if missing_keys:
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
version = wallet_version.strip().upper()
if version not in SUPPORTED_WALLET_VERSIONS:
raise ConfigurationError(
ConfigurationError.UNSUPPORTED_VERSION.format(
version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS))
)
)
self.seed: str = seed.strip()
self.api_key: str = api_key.strip()
self.cookies: dict[str, Any] = cast(dict[str, Any], cookies)
self.wallet_version: WalletVersion = version # type: ignore[assignment]
self.timeout: float = timeout
async def __aenter__(self) -> FragmentClient:
return self
async def __aexit__(self, *_: object) -> None:
pass
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:
"""Gift Telegram Premium to a user.
Args:
username: Recipient's Telegram username (with or without ``@``).
months: Duration — ``3``, ``6``, or ``12``.
show_sender: Show your name as the sender. Defaults to ``True``.
Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
"""
return await purchase_premium(self, username, months, show_sender)
async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult:
"""Send Telegram Stars to a user.
Args:
username: Recipient's Telegram username (with or without ``@``).
amount: Number of stars — integer from ``50`` to ``1 000 000``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
"""
return await purchase_stars(self, username, amount, show_sender)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up TON to a recipient's Telegram balance.
Args:
username: Recipient's Telegram username (with or without ``@``).
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
show_sender: Show your name as the sender. Defaults to ``True``.
Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
"""
return await topup_ton(self, username, amount, show_sender)
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account.
Args:
account: Your Fragment Ads account identifier — the channel or bot username
the Ads account is linked to (e.g. ``"@mychannel"``).
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
Returns:
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
"""
return await recharge_ads(self, account, amount)
async def get_wallet(self) -> WalletInfo:
"""Return the address, state and balance of the TON wallet.
Returns:
:class:`WalletInfo` with ``address`` (``"UQ..."``), ``state``
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON.
"""
return await get_wallet_info(self)
async def giveaway_stars(
self,
channel: str,
winners: int,
amount: int,
) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel.
Args:
channel: Channel username (with or without ``@``).
winners: Number of winners — integer from ``1`` to ``5``.
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
return await giveaway_stars(self, channel, winners, amount)
async def giveaway_premium(
self,
channel: str,
winners: int,
months: int = 3,
) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel.
Args:
channel: Channel username (with or without ``@``).
winners: Number of winners — positive integer.
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
"""
return await giveaway_premium(self, channel, winners, months)
async def get_login_code(self, number: str) -> LoginCodeResult:
"""Fetch the current pending login code for an anonymous number.
Args:
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
Returns:
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending),
and ``active_sessions`` count.
"""
return await get_login_code(self, number)
async def toggle_login_codes(self, number: str, can_receive: bool) -> None:
"""Enable or disable login code delivery for an anonymous number.
Args:
number: Phone number with or without leading ``+``.
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
"""
return await toggle_login_codes(self, number, can_receive)
async def terminate_sessions(self, number: str) -> TerminateSessionsResult:
"""Terminate all active Telegram sessions for an anonymous number.
Args:
number: Phone number with or without leading ``+``.
Returns:
:class:`TerminateSessionsResult` with ``number`` and ``message``.
Raises:
AnonymousNumberError: If the number is not owned by this account or has no active sessions.
"""
return await terminate_sessions(self, number)
async def search_usernames(
self,
query: str = "",
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> UsernamesResult:
"""Search the Fragment marketplace for Telegram usernames.
Args:
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id`
from a previous result to fetch the next page.
Returns:
:class:`UsernamesResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
"""
return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id)
async def search_numbers(
self,
query: str = "",
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> NumbersResult:
"""Search the Fragment marketplace for anonymous Telegram numbers.
Args:
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id`
from a previous result to fetch the next page.
Returns:
:class:`NumbersResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
"""
return await search_numbers(self, query, sort=sort, filter=filter, offset_id=offset_id)
async def search_gifts(
self,
query: str = "",
collection: str | None = None,
sort: str | None = None,
filter: str | None = None,
view: str | None = None,
attr: dict[str, list[str]] | None = None,
offset: int | None = None,
) -> GiftsResult:
"""Search the Fragment gifts marketplace.
Args:
query: Search text. Omit or pass ``""`` to browse without filtering by name.
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
attr: Attribute filters — mapping of trait name to accepted values, e.g.
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
Each key is sent as ``attr[Key]`` with its list of values.
offset: Integer page offset from a previous :class:`GiftsResult`.
Pass ``next_offset`` to fetch the next page.
Returns:
:class:`GiftsResult` with ``items`` (parsed list of item dicts)
and ``next_offset`` (``None`` on the last page).
"""
return await search_gifts(
self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset
)
async def call(
self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL
) -> dict[str, Any]:
"""Send a raw request to the Fragment API.
Useful for accessing undocumented or future Fragment API methods
without waiting for a library update.
Args:
method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``.
data: Additional form-data fields to include in the request body.
page_url: Fragment page URL used to derive the API hash and headers.
Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``).
Returns:
Raw parsed JSON response as a dict.
Example::
result = await client.call(
"searchPremiumGiftRecipient",
{"query": "@username", "months": 3},
page_url="https://fragment.com/premium/gift",
)
"""
headers = make_headers(page_url)
async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session:
fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout)
return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})})
-25
View File
@@ -1,25 +0,0 @@
from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes
from pyfragment.methods.giveaway_premium import giveaway_premium
from pyfragment.methods.giveaway_stars import giveaway_stars
from pyfragment.methods.purchase_premium import purchase_premium
from pyfragment.methods.purchase_stars import purchase_stars
from pyfragment.methods.recharge_ads import recharge_ads
from pyfragment.methods.search_gifts import search_gifts
from pyfragment.methods.search_numbers import search_numbers
from pyfragment.methods.search_usernames import search_usernames
from pyfragment.methods.topup_ton import topup_ton
__all__ = [
"get_login_code",
"giveaway_premium",
"giveaway_stars",
"purchase_premium",
"purchase_stars",
"recharge_ads",
"search_gifts",
"search_numbers",
"search_usernames",
"terminate_sessions",
"toggle_login_codes",
"topup_ton",
]
-143
View File
@@ -1,143 +0,0 @@
from __future__ import annotations
import html
from typing import TYPE_CHECKING
from pyfragment.types import (
AnonymousNumberError,
FragmentAPIError,
FragmentError,
LoginCodeResult,
TerminateSessionsResult,
UnexpectedError,
)
from pyfragment.types.constants import NUMBERS_PAGE
from pyfragment.utils import parse_login_code
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
def _strip_plus(number: str) -> str:
return number.lstrip("+") if isinstance(number, str) else number
async def get_login_code(client: FragmentClient, number: str) -> LoginCodeResult:
"""Fetch the current pending login code for an anonymous number.
Args:
client: Authenticated :class:`FragmentClient` instance.
number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``).
Returns:
:class:`LoginCodeResult` with ``number``, ``code`` (``None`` if no pending code),
and ``active_sessions`` count.
Raises:
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
try:
clean = _strip_plus(number)
result = await client.call(
"updateLoginCodes",
{"number": clean, "lt": "0", "from_app": "1"},
page_url=NUMBERS_PAGE,
)
if result.get("html"):
code, active_sessions = parse_login_code(result["html"])
else:
code, active_sessions = None, 0
return LoginCodeResult(number=number, code=code, active_sessions=active_sessions)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def toggle_login_codes(client: FragmentClient, number: str, can_receive: bool) -> None:
"""Enable or disable login code delivery for an anonymous number.
Args:
client: Authenticated :class:`FragmentClient` instance.
number: Phone number with or without leading ``+``.
can_receive: ``True`` to allow receiving codes, ``False`` to block them.
Raises:
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
try:
clean = _strip_plus(number)
result = await client.call(
"toggleLoginCodes",
{"number": clean, "can_receive": 1 if can_receive else 0},
page_url=NUMBERS_PAGE,
)
if result.get("error"):
raise FragmentAPIError(html.unescape(result["error"]))
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
async def terminate_sessions(client: FragmentClient, number: str) -> TerminateSessionsResult:
"""Terminate all active Telegram sessions for an anonymous number.
This is a two-step operation: Fragment first returns a confirmation hash,
which is then submitted to confirm the termination.
Args:
client: Authenticated :class:`FragmentClient` instance.
number: Phone number with or without leading ``+``.
Returns:
:class:`TerminateSessionsResult` with ``number`` and ``message``.
Raises:
AnonymousNumberError: If the number is not owned by this account or has no active sessions,
or if Fragment returns an error during termination.
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
try:
clean = _strip_plus(number)
confirmation = await client.call(
"terminatePhoneSessions",
{"number": clean},
page_url=NUMBERS_PAGE,
)
if confirmation.get("error"):
raise AnonymousNumberError(
AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(confirmation["error"]))
)
terminate_hash = confirmation.get("terminate_hash")
if not terminate_hash:
raise AnonymousNumberError(AnonymousNumberError.NOT_OWNED.format(number=number))
result = await client.call(
"terminatePhoneSessions",
{"number": clean, "terminate_hash": terminate_hash},
page_url=NUMBERS_PAGE,
)
if result.get("error"):
raise AnonymousNumberError(
AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(result["error"]))
)
return TerminateSessionsResult(number=number, message=result.get("msg"))
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-95
View File
@@ -1,95 +0,0 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
PremiumGiveawayResult,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def giveaway_premium(
client: FragmentClient,
channel: str,
winners: int,
months: int = 3,
) -> PremiumGiveawayResult:
"""Run a Telegram Premium giveaway for a channel.
Args:
client: Authenticated :class:`FragmentClient` instance.
channel: Channel username (with or without ``@``).
winners: Number of winners — integer from ``1`` to ``24 000``.
months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``.
Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
Raises:
ConfigurationError: If ``winners`` is not 124 000 or ``months`` is not 3, 6, or 12.
UserNotFoundError: If the channel is not found on Fragment.
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
if not isinstance(winners, int) or not (1 <= winners <= 24_000):
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM)
if months not in (3, 6, 12):
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
try:
result = await client.call(
"searchPremiumGiveawayRecipient",
{"query": channel, "quantity": winners, "months": months},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
result = await client.call(
"initGiveawayPremiumRequest",
{"recipient": recipient, "quantity": str(winners), "months": str(months)},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway"))
account = await get_account_info(client)
transaction = await client.call(
"getGiveawayPremiumLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
},
page_url=PREMIUM_GIVEAWAY_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return PremiumGiveawayResult(
transaction_id=tx_hash,
channel=channel,
winners=winners,
amount=months,
)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-91
View File
@@ -1,91 +0,0 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
StarsGiveawayResult,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def giveaway_stars(
client: FragmentClient,
channel: str,
winners: int,
amount: int,
) -> StarsGiveawayResult:
"""Run a Telegram Stars giveaway for a channel.
Args:
client: Authenticated :class:`FragmentClient` instance.
channel: Channel username (with or without ``@``).
winners: Number of winners — integer from ``1`` to ``5``.
amount: Stars each winner receives — integer from ``500`` to ``1 000 000``.
Returns:
:class:`StarsGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``amount``.
Raises:
ConfigurationError: If ``winners`` is not 15 or ``amount`` is not 5001 000 000.
UserNotFoundError: If the channel is not found on Fragment.
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
if not isinstance(winners, int) or not (1 <= winners <= 5):
raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS)
if not isinstance(amount, int) or not (500 <= amount <= 1_000_000):
raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER)
try:
result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel))
result = await client.call(
"initGiveawayStarsRequest",
{"recipient": recipient, "quantity": str(winners), "stars": str(amount)},
page_url=STARS_GIVEAWAY_PAGE,
)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway"))
account = await get_account_info(client)
transaction = await client.call(
"getGiveawayStarsLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
},
page_url=STARS_GIVEAWAY_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return StarsGiveawayResult(
transaction_id=tx_hash,
channel=channel,
winners=winners,
amount=amount,
)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-81
View File
@@ -1,81 +0,0 @@
from __future__ import annotations
import json
import time
from typing import TYPE_CHECKING
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
PremiumResult,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, PREMIUM_PAGE
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def purchase_premium(client: FragmentClient, username: str, months: int, show_sender: bool = True) -> PremiumResult:
"""Gift Telegram Premium to a user.
Args:
client: Authenticated :class:`FragmentClient` instance.
username: Recipient's Telegram username (with or without ``@``).
months: Premium duration — ``3``, ``6``, or ``12``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
Raises:
ConfigurationError: If ``months`` is not ``3``, ``6``, or ``12``.
UserNotFoundError: If the user is not found on Fragment.
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
if months not in (3, 6, 12):
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
try:
result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
await client.call(
"updatePremiumState",
{"mode": "new", "lv": "false", "dh": str(int(time.time()))},
page_url=PREMIUM_PAGE,
)
result = await client.call("initGiftPremiumRequest", {"recipient": recipient, "months": months}, page_url=PREMIUM_PAGE)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
account = await get_account_info(client)
transaction = await client.call(
"getGiftPremiumLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
},
page_url=PREMIUM_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return PremiumResult(transaction_id=tx_hash, username=username, amount=months)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-75
View File
@@ -1,75 +0,0 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
StarsResult,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, STARS_PAGE
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def purchase_stars(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> StarsResult:
"""Send Telegram Stars to a user.
Args:
client: Authenticated :class:`FragmentClient` instance.
username: Recipient's Telegram username (with or without ``@``).
amount: Number of Stars to send — integer from ``50`` to ``1 000 000``.
show_sender: Show your name as the gift sender. Defaults to ``True``.
Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
Raises:
ConfigurationError: If ``amount`` is not an integer between 50 and 1 000 000.
UserNotFoundError: If the user is not found on Fragment.
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
try:
result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
result = await client.call("initBuyStarsRequest", {"recipient": recipient, "quantity": amount}, page_url=STARS_PAGE)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
account = await get_account_info(client)
transaction = await client.call(
"getBuyStarsLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
},
page_url=STARS_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return StarsResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-69
View File
@@ -1,69 +0,0 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from pyfragment.types import (
AdsRechargeResult,
ConfigurationError,
FragmentAPIError,
FragmentError,
UnexpectedError,
VerificationError,
)
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def recharge_ads(client: FragmentClient, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account.
Args:
client: Authenticated :class:`FragmentClient` instance.
account: Your Fragment Ads account identifier — the channel or bot username
the Ads account is linked to (e.g. ``"@mychannel"``).
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
Returns:
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
Raises:
ConfigurationError: If ``amount`` is not a valid integer in the allowed range.
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
try:
await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
result = await client.call("initAdsRechargeRequest", {"account": account, "amount": amount}, page_url=ADS_TOPUP_PAGE)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge"))
account_info = await get_account_info(client)
transaction = await client.call(
"getAdsRechargeLink",
{
"account": json.dumps(account_info),
"device": DEVICE,
"transaction": 1,
"id": req_id,
},
page_url=ADS_TOPUP_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-75
View File
@@ -1,75 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from pyfragment.types import FragmentAPIError, FragmentError, GiftsResult, UnexpectedError
from pyfragment.types.constants import GIFTS_PAGE
from pyfragment.utils import parse_gift_items
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def search_gifts(
client: FragmentClient,
query: str = "",
collection: str | None = None,
sort: str | None = None,
filter: str | None = None,
view: str | None = None,
attr: dict[str, list[str]] | None = None,
offset: int | None = None,
) -> GiftsResult:
"""Search the Fragment gifts marketplace.
Args:
client: Authenticated :class:`FragmentClient` instance.
query: Search text. Omit or pass ``""`` to browse without filtering by name.
collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
(available items). Omit to return all.
view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default.
attr: Attribute filters as a mapping of trait name to list of accepted values, e.g.
``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``.
Each key is sent as ``attr[Key]`` with its list of values.
offset: Integer page offset from a previous :class:`GiftsResult`.
Pass ``next_offset`` to fetch the next page.
Returns:
:class:`GiftsResult` with ``items`` (parsed list of item dicts) and
``next_offset`` (``None`` on the last page).
Raises:
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
data: dict[str, Any] = {"type": "gifts", "query": query}
if collection is not None:
data["collection"] = collection
if sort is not None:
data["sort"] = sort
if filter is not None:
data["filter"] = filter
if view is not None:
data["view"] = view
if attr is not None:
for trait, values in attr.items():
data[f"attr[{trait}]"] = values
if offset is not None:
data["offset"] = offset
try:
result = await client.call("searchAuctions", data, page_url=GIFTS_PAGE)
if result.get("error"):
raise FragmentAPIError(result["error"])
items, next_offset = parse_gift_items(result.get("html") or "")
return GiftsResult(items=items, next_offset=next_offset)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-62
View File
@@ -1,62 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from pyfragment.types import FragmentAPIError, FragmentError, NumbersResult, UnexpectedError
from pyfragment.types.constants import NUMBERS_PAGE
from pyfragment.utils import parse_auction_rows
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def search_numbers(
client: FragmentClient,
query: str = "",
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> NumbersResult:
"""Search the Fragment marketplace for anonymous Telegram numbers.
Args:
client: Authenticated :class:`FragmentClient` instance.
query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
(available items). Omit to return all.
offset_id: Pagination cursor from a previous :class:`NumbersResult`.
Pass ``next_offset_id`` to fetch the next page.
Returns:
:class:`NumbersResult` with ``items`` (parsed list of item dicts) and
``next_offset_id`` (``None`` when there are no more pages).
Raises:
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
data: dict[str, Any] = {"type": "numbers", "query": query}
if sort is not None:
data["sort"] = sort
if filter is not None:
data["filter"] = filter
if offset_id is not None:
data["offset_id"] = offset_id
try:
result = await client.call("searchAuctions", data, page_url=NUMBERS_PAGE)
if result.get("error"):
raise FragmentAPIError(result["error"])
items = parse_auction_rows(result.get("html") or "")
raw_noi = result.get("next_offset_id")
next_offset_id = str(raw_noi) if raw_noi else None
return NumbersResult(items=items, next_offset_id=next_offset_id)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-62
View File
@@ -1,62 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError, UsernamesResult
from pyfragment.types.constants import FRAGMENT_BASE_URL
from pyfragment.utils import parse_auction_rows
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def search_usernames(
client: FragmentClient,
query: str = "",
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> UsernamesResult:
"""Search the Fragment marketplace for Telegram usernames.
Args:
client: Authenticated :class:`FragmentClient` instance.
query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
(available items). Omit to return all.
offset_id: Pagination cursor from a previous :class:`UsernamesResult`.
Pass ``next_offset_id`` to fetch the next page.
Returns:
:class:`UsernamesResult` with ``items`` (parsed list of item dicts) and
``next_offset_id`` (``None`` when there are no more pages).
Raises:
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
data: dict[str, Any] = {"type": "usernames", "query": query}
if sort is not None:
data["sort"] = sort
if filter is not None:
data["filter"] = filter
if offset_id is not None:
data["offset_id"] = offset_id
try:
result = await client.call("searchAuctions", data, page_url=FRAGMENT_BASE_URL)
if result.get("error"):
raise FragmentAPIError(result["error"])
items = parse_auction_rows(result.get("html") or "")
raw_noi = result.get("next_offset_id")
next_offset_id = str(raw_noi) if raw_noi else None
return UsernamesResult(items=items, next_offset_id=next_offset_id)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
-77
View File
@@ -1,77 +0,0 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from pyfragment.types import (
AdsTopupResult,
ConfigurationError,
FragmentAPIError,
FragmentError,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def topup_ton(client: FragmentClient, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up TON to a recipient's Telegram balance.
Args:
client: Authenticated :class:`FragmentClient` instance.
username: Recipient's Telegram username (with or without ``@``).
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
show_sender: Show your name as the sender. Defaults to ``True``.
Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
Raises:
ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000.
UserNotFoundError: If the recipient is not found on Telegram.
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
try:
await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE)
result = await client.call("searchAdsTopupRecipient", {"query": username}, page_url=ADS_TOPUP_PAGE)
recipient = result.get("found", {}).get("recipient")
if not recipient:
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
account = await get_account_info(client)
transaction = await client.call(
"getAdsTopupLink",
{
"account": json.dumps(account),
"device": DEVICE,
"transaction": 1,
"id": req_id,
"show_sender": int(show_sender),
},
page_url=ADS_TOPUP_PAGE,
)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
View File
-64
View File
@@ -1,64 +0,0 @@
from pyfragment.types.exceptions import (
AnonymousNumberError,
ClientError,
ConfigurationError,
CookieError,
FragmentAPIError,
FragmentError,
FragmentPageError,
OperationError,
ParseError,
TransactionError,
UnexpectedError,
UserNotFoundError,
VerificationError,
WalletError,
)
from pyfragment.types.results import (
AdsRechargeResult,
AdsTopupResult,
CookieResult,
GiftsResult,
LoginCodeResult,
NumbersResult,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
StarsResult,
TerminateSessionsResult,
UsernamesResult,
WalletInfo,
)
__all__ = [
# client exceptions
"ClientError",
"ConfigurationError",
"CookieError",
# fragment exceptions
"FragmentAPIError",
"FragmentError",
"FragmentPageError",
"AnonymousNumberError",
"OperationError",
"ParseError",
"TransactionError",
"UnexpectedError",
"UserNotFoundError",
"VerificationError",
"WalletError",
# result types
"AdsRechargeResult",
"AdsTopupResult",
"CookieResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult",
"UsernamesResult",
"WalletInfo",
]
-85
View File
@@ -1,85 +0,0 @@
from __future__ import annotations
import json
from typing import Any, Literal, get_args
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
# Single source of truth for supported wallet versions
WalletVersion = Literal["V4R2", "V5R1"]
SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
WALLET_CLASSES: dict[str, Any] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
# Minimum wallet balance required to cover TON network gas fees.
MIN_TON_BALANCE: float = 0.056
# Default HTTP request timeout in seconds.
DEFAULT_TIMEOUT: float = 30.0
# Required Fragment session cookie keys
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
# Fragment domain and page URLs
FRAGMENT_DOMAIN: str = "fragment.com" # for rookiepy
FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}"
STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway"
PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift"
PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway"
ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts"
# Browsers supported by get_cookies_from_browser()
SUPPORTED_BROWSERS: frozenset[str] = frozenset(
{
"arc",
"brave",
"chrome",
"chromium",
"chromium_based",
"edge",
"firefox",
"firefox_based",
"librewolf",
"opera",
"opera_gx",
"safari",
"vivaldi",
}
)
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
DEVICE: str = json.dumps(
{
"platform": "iphone",
"appName": "Tonkeeper",
"appVersion": "26.04.0",
"maxProtocolVersion": 2,
"features": [
"SendTransaction",
{"name": "SendTransaction", "maxMessages": 255},
{"name": "SignData", "types": ["text", "binary", "cell"]},
],
}
)
# Base HTTP headers — shared across all Fragment API requests.
# Each method merges these with its own "referer" and "x-aj-referer".
BASE_HEADERS: dict[str, str] = {
"accept": "application/json, text/javascript, */*; q=0.01",
"accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"origin": FRAGMENT_BASE_URL,
"priority": "u=1, i",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
),
"x-requested-with": "XMLHttpRequest",
}
-163
View File
@@ -1,163 +0,0 @@
from __future__ import annotations
class FragmentError(Exception):
"""Base exception for all pyfragment library errors."""
class ClientError(FragmentError):
"""Raised for client configuration and setup issues (bad params, invalid cookies)."""
class ConfigurationError(ClientError):
"""Raised when required client parameters are missing or invalid."""
MISSING_VARS = "Missing required parameter(s): {keys}."
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
INVALID_API_KEY = (
"Invalid Tonapi API key: expected at least 68 characters, got {length}. Generate a key at https://tonconsole.com."
)
INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months."
INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000."
INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1 000 000 000."
INVALID_USERNAME = (
"Invalid username '{username}'. "
"Must be 532 characters and contain only letters (AZ, az), digits (09), or underscores (_)."
)
INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5."
INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000."
INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000."
class CookieError(ClientError):
"""Raised when cookies are unreadable or missing required fields."""
READ_FAILED = "Failed to parse cookies — expected a JSON string or a dict, got: {exc}"
MISSING_KEYS = (
"Fragment cookies are missing or empty for key(s): {keys}. "
"Open fragment.com in your browser, log in, and copy fresh cookies."
)
UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}."
BROWSER_READ_FAILED = (
"Failed to read {browser} cookies: {exc}. Make sure {browser} is installed and you are logged in to {url}."
)
MISSING_BROWSER_KEYS = (
"Fragment cookies not found in {browser}: {keys}. "
"Make sure you are logged in to {url} and have connected your TON wallet in {browser}."
)
EXPIRED = "Fragment session cookie expired at {expires}. Log in to fragment.com in your browser and extract fresh cookies."
class FragmentAPIError(FragmentError):
"""Raised for errors returned by Fragment's API responses."""
NO_REQUEST_ID = (
"Fragment did not return a request ID for '{context}'. "
"Your session may have expired — log in to fragment.com and refresh your cookies."
)
class FragmentPageError(FragmentAPIError):
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
BAD_STATUS = (
"Fragment returned HTTP {status} when loading {url}. "
"Your cookies may be invalid or expired — log in to fragment.com and refresh them."
)
NOT_FOUND = (
"Could not extract the API hash from {url}. "
"The page structure may have changed, or you are not logged in — refresh your cookies."
)
class UserNotFoundError(FragmentAPIError):
"""Raised when the target Telegram user is not found on Fragment."""
NOT_FOUND = (
"Telegram user '{username}' was not found on Fragment. Double-check the username and make sure the account exists."
)
class AnonymousNumberError(FragmentAPIError):
"""Raised for Fragment anonymous number API failures."""
NOT_OWNED = "Number '{number}' is not associated with your Fragment account or has no active sessions to terminate."
TERMINATE_FAILED = "Failed to terminate sessions for '{number}': {error}"
class TransactionError(FragmentAPIError):
"""Raised when a TON transaction fails to build or broadcast."""
INVALID_PAYLOAD = (
"Fragment returned an invalid transaction payload — 'transaction.messages' is missing or empty in the API response."
)
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
BROADCAST_FAILED_SSL = (
"Transaction broadcast failed due to an SSL certificate error: {exc}\n"
"This usually means your system's CA bundle is missing or outdated.\n"
"Fix: run `pip install --upgrade certifi` and retry. "
"On macOS you may also need to run the 'Install Certificates.command' "
"located in your Python installation folder."
)
DUPLICATE_SEQNO = (
"Transaction broadcast failed: the TON wallet rejected the message "
"because a previous transaction with the same sequence number (seqno) "
"is still pending confirmation on-chain.\n"
"Wait a few seconds for the previous transaction to confirm, then retry."
)
class ParseError(FragmentAPIError):
"""Raised when a Fragment API response or payload cannot be parsed."""
UNPARSEABLE = "Failed to parse the Fragment API response for '{context}': {exc}"
class VerificationError(FragmentAPIError):
"""Raised when Fragment requires KYC verification before proceeding."""
KYC_REQUIRED = (
"Fragment requires identity verification (KYC) before this action can be completed. "
"Complete verification at https://fragment.com/my/profile and retry."
)
class OperationError(FragmentError):
"""Raised for runtime operation failures unrelated to Fragment's API."""
class WalletError(OperationError):
"""Raised for TON wallet issues (connection, balance, account info)."""
LOW_BALANCE = (
"Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required "
"(transaction amount + {gas:.3f} TON gas reserve)."
)
BALANCE_CHECK_FAILED = "Failed to fetch wallet balance: {exc}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}"
WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}"
class UnexpectedError(OperationError):
"""Raised when an unexpected error occurs during an API call."""
UNEXPECTED = "An unexpected error occurred during the operation: {exc}"
__all__ = [
"FragmentError",
"ClientError",
"ConfigurationError",
"CookieError",
"FragmentAPIError",
"FragmentPageError",
"AnonymousNumberError",
"UserNotFoundError",
"TransactionError",
"ParseError",
"VerificationError",
"OperationError",
"WalletError",
"UnexpectedError",
]
-218
View File
@@ -1,218 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class CookieResult:
"""Result returned by :func:`~pyfragment.utils.get_cookies_from_browser`.
Attributes:
cookies: Dict with the four required Fragment cookie keys.
expires: Expiry of the ``stel_ssid`` session cookie in ISO 8601 format (UTC),
or ``None`` for session cookies.
"""
cookies: dict[str, str]
expires: str | None
def __repr__(self) -> str:
return f"CookieResult(expires={self.expires!r})"
@dataclass
class WalletInfo:
"""Wallet state returned by :meth:`FragmentClient.get_wallet`."""
address: str
state: str
balance: float
def __repr__(self) -> str:
return f"WalletInfo(address='{self.address}', state='{self.state}', balance={self.balance} TON)"
@dataclass
class PremiumResult:
"""Result of a successful Telegram Premium gift."""
transaction_id: str
username: str
amount: int
def __repr__(self) -> str:
return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')"
@dataclass
class StarsResult:
"""Result of a successful Telegram Stars purchase."""
transaction_id: str
username: str
amount: int
def __repr__(self) -> str:
return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')"
@dataclass
class AdsTopupResult:
"""Result of a successful Telegram Ads balance top-up."""
transaction_id: str
username: str
amount: int
def __repr__(self) -> str:
return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')"
@dataclass
class StarsGiveawayResult:
"""Result of a successful Telegram Stars giveaway."""
transaction_id: str
channel: str
winners: int
amount: int
def __repr__(self) -> str:
return (
f"StarsGiveawayResult(channel='{self.channel}', winners={self.winners}, "
f"amount={self.amount} stars per winner, tx='{self.transaction_id}')"
)
@dataclass
class PremiumGiveawayResult:
"""Result of a successful Telegram Premium giveaway."""
transaction_id: str
channel: str
winners: int
amount: int
def __repr__(self) -> str:
return (
f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, "
f"amount={self.amount} months per winner, tx='{self.transaction_id}')"
)
@dataclass
class LoginCodeResult:
"""Result of :meth:`FragmentClient.get_login_code`."""
number: str
code: str | None
active_sessions: int
def __repr__(self) -> str:
code_str = f"'{self.code}'" if self.code else "None"
return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})"
@dataclass
class AdsRechargeResult:
"""Result of a successful self-recharge of Telegram Ads balance."""
transaction_id: str
amount: int
def __repr__(self) -> str:
return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')"
@dataclass
class TerminateSessionsResult:
"""Result of :meth:`FragmentClient.terminate_sessions`."""
number: str
message: str | None
def __repr__(self) -> str:
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})"
@dataclass
class UsernamesResult:
"""Result of :meth:`FragmentClient.search_usernames`.
Each dict in ``items`` has the keys:
- ``slug`` — URL path (e.g. ``"username/durov"``).
- ``name`` — display value (e.g. ``"@durov"``).
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``.
- ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``.
Use ``next_offset_id`` to paginate to the next page of results.
"""
items: list[dict[str, Any]]
next_offset_id: str | None
def __repr__(self) -> str:
return f"UsernamesResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
@dataclass
class NumbersResult:
"""Result of :meth:`FragmentClient.search_numbers`.
Each dict in ``items`` has the keys:
- ``slug`` — URL path (e.g. ``"number/8880000111"``).
- ``name`` — display value (e.g. ``"+888 0000 111"``).
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``.
- ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``.
Use ``next_offset_id`` to paginate to the next page of results.
"""
items: list[dict[str, Any]]
next_offset_id: str | None
def __repr__(self) -> str:
return f"NumbersResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
@dataclass
class GiftsResult:
"""Result of :meth:`FragmentClient.search_gifts`.
Each dict in ``items`` has the keys:
- ``slug`` — URL path (e.g. ``"gift/plushpepe-1821"``).
- ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``).
- ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``).
- ``price`` — price in TON formatted to two decimal places (e.g. ``"88888.00"``), or ``None``.
- ``date`` — ISO 8601 datetime of the sale/listing, or ``None``.
Use ``next_offset`` to paginate to the next page of results.
"""
items: list[dict[str, Any]]
next_offset: int | None
def __repr__(self) -> str:
return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})"
__all__ = [
"AdsRechargeResult",
"AdsTopupResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
"PremiumGiveawayResult",
"PremiumResult",
"StarsGiveawayResult",
"StarsResult",
"TerminateSessionsResult",
"UsernamesResult",
"WalletInfo",
]
-27
View File
@@ -1,27 +0,0 @@
from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser
from pyfragment.utils.decoder import clean_decode
from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code
from pyfragment.utils.http import (
execute_transaction_request,
fragment_request,
get_fragment_hash,
make_headers,
parse_json_response,
)
from pyfragment.utils.wallet import get_account_info, process_transaction
__all__ = [
"clean_decode",
"CookieResult",
"get_cookies_from_browser",
"parse_auction_rows",
"parse_gift_items",
"parse_login_code",
"execute_transaction_request",
"fragment_request",
"get_account_info",
"get_fragment_hash",
"make_headers",
"parse_json_response",
"process_transaction",
]
-72
View File
@@ -1,72 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
import rookiepy
from pyfragment.types import CookieError
from pyfragment.types import CookieResult as CookieResult
from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
"""Extract Fragment session cookies directly from an installed browser.
Reads the browser's on-disk cookie store (no extension required) and
returns the four cookies required by :class:`~pyfragment.FragmentClient`
along with the session expiry timestamp.
Args:
browser: Browser name to read cookies from — case-insensitive. Supported values:
``"chrome"`` (default), ``"firefox"``, ``"edge"``, ``"brave"``, ``"arc"``,
``"opera"``, ``"opera_gx"``, ``"chromium"``, ``"chromium_based"``,
``"firefox_based"``, ``"vivaldi"``, ``"librewolf"``, ``"safari"``.
Returns:
:class:`CookieResult` with ``.cookies`` (dict) and ``.expires`` (ISO 8601 string or ``None``).
Raises:
CookieError: If the browser is not supported, cookies cannot be read,
or required keys are missing.
"""
key = browser.lower()
if key not in SUPPORTED_BROWSERS:
supported = ", ".join(sorted(SUPPORTED_BROWSERS))
raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported))
try:
jar: list[dict[str, Any]] = getattr(rookiepy, key)([FRAGMENT_DOMAIN])
except Exception as exc:
raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc
cookie_map: dict[str, str] = {c["name"]: c["value"] for c in jar if c.get("name") and c.get("value")}
missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookie_map.get(k, "")).strip()]
if missing:
raise CookieError(CookieError.MISSING_BROWSER_KEYS.format(browser=browser, keys=missing, url=FRAGMENT_BASE_URL))
expires_iso: str | None = None
for cookie in jar:
if cookie.get("name") == "stel_ssid":
raw = cookie.get("expires")
if isinstance(raw, (int, float)):
expires_iso = datetime.fromtimestamp(raw, tz=timezone.utc).isoformat()
elif isinstance(raw, str) and raw:
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
try:
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc).isoformat()
break
except ValueError:
continue
break
if expires_iso:
expires_dt = datetime.fromisoformat(expires_iso)
if expires_dt < datetime.now(timezone.utc):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
return CookieResult(
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
expires=expires_iso,
)
-37
View File
@@ -1,37 +0,0 @@
from __future__ import annotations
import base64
from ton_core import Cell
from pyfragment.types import ParseError
def clean_decode(payload: str) -> str:
"""Decode a base64-encoded BOC payload to a plain-text comment string.
Fragment transaction payloads are BOC-serialised TVM cells. This function
base64-decodes the payload, parses the cell, skips the 32-bit op-code
prefix, and reads the snake-encoded UTF-8 comment.
Args:
payload: Base64url-encoded BOC string (padding is added automatically).
Returns:
Decoded comment string, or ``""`` for an empty payload.
Raises:
ParseError: If the payload cannot be decoded or parsed.
"""
s = payload.strip()
if not s:
return ""
s += "=" * (-len(s) % 4)
try:
boc = base64.b64decode(s)
cell = Cell.one_from_boc(boc)
sl = cell.begin_parse()
sl.load_uint(32) # op code — always 0 for text comment
return sl.load_snake_string().strip()
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
-163
View File
@@ -1,163 +0,0 @@
from __future__ import annotations
import re
from typing import Any
# Matches the login code inside a table-cell-value element.
CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
# Counts active session rows in the HTML table.
ROW_RE = re.compile(r"<tr[\s>]")
# Auction table row parsing
ROW_BLOCK_RE = re.compile(r'<tr\b[^>]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)</tr>', re.DOTALL)
HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"')
VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<')
PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<")
DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>')
DATETIME_SHORT_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>')
# Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888")
NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$")
# Gift grid item parsing
GRID_ITEM_RE = re.compile(r'<a\b[^>]*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)</a>', re.DOTALL)
GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))')
GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<')
GRID_NUM_RE = re.compile(r'class="item-num">[^#]*#(\w+)<')
GRID_PRICE_RE = re.compile(r'class="[^"]*tm-grid-item-value[^"]*icon-ton[^"]*"[^>]*>\s*([0-9][^<]*?)\s*<')
GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<]+?)\s*<')
GRID_DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"')
def parse_login_code(html: str) -> tuple[str | None, int]:
"""Extract the pending login code and active session count from a Fragment numbers page HTML snippet.
Args:
html: Raw HTML string returned by the Fragment API.
Returns:
A tuple of ``(code, active_sessions)`` where ``code`` is ``None`` if no
pending code is present, and ``active_sessions`` is the number of ``<tr>``
rows found (each row represents one active session).
"""
match = CODE_RE.search(html)
code = match.group(1).strip() if match else None
active_sessions = len(ROW_RE.findall(html))
return code, active_sessions
def parse_auction_rows(html: str) -> list[dict[str, Any]]:
"""Parse Fragment marketplace HTML into structured item dicts.
Extracts each ``<tr class="tm-row-selectable">`` and returns a list of dicts
with the following keys:
- ``slug`` — URL path segment (e.g. ``"username/durov"``).
- ``name`` — display value (e.g. ``"@durov"`` or ``"+888..."``)
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``),
or ``None`` if not listed.
- ``date`` — ISO 8601 datetime string: auction end date, sale date, or listing date, or ``None``.
Returns:
List of item dicts, one per table row.
"""
items: list[dict[str, Any]] = []
for row_match in ROW_BLOCK_RE.finditer(html):
row = row_match.group(1)
href_m = HREF_RE.search(row)
if not href_m:
continue
slug = href_m.group(1).lstrip("/") # e.g. "username/durov"
# All tm-value spans in the row — first is the display name
values = [m.group(1).strip() for m in VALUE_RE.finditer(row)]
name = values[0] if values else slug
# Status: find the human-readable label from subsequent tm-value spans.
# Skip usernames (@), numeric-only values (prices like "150,492", phone numbers like "+888 0088 8888").
status: str | None = None
for v in values[1:]:
if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v):
status = v
break
# Price — look for icon-ton pattern, format as two decimal places
price_m = PRICE_RE.search(row)
price: str | None = None
if price_m:
raw_price = price_m.group(1).strip().replace(",", "")
try:
price = f"{float(raw_price):.2f}"
except ValueError:
price = raw_price
# Datetime (ISO 8601) — auction end, sale date, or listing date.
time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row)
date: str | None = time_m.group(1) if time_m else None
items.append(
{
"slug": slug,
"name": name,
"status": status,
"price": price,
"date": date,
}
)
return items
def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]:
"""Parse Fragment gifts grid HTML into structured item dicts.
Extracts each ``<a class="tm-grid-item">`` block and returns a list of dicts
with the following keys:
- ``slug`` — URL path segment (e.g. ``"gift/plushpepe-1821"``).
- ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``).
- ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``).
- ``price`` — price in TON formatted to two decimal places, or ``None``.
- ``date`` — ISO 8601 datetime of the sale/listing, or ``None``.
Returns:
Tuple of ``(items, next_offset)`` where ``next_offset`` is an integer
page offset from ``data-next-offset``, or ``None`` on the last page.
"""
items: list[dict[str, Any]] = []
for item_match in GRID_ITEM_RE.finditer(html):
block = item_match.group(0)
href_m = GRID_HREF_RE.search(block)
if not href_m:
continue
slug = href_m.group(1).lstrip("/") # e.g. "gift/plushpepe-1821"
name_m = GRID_NAME_RE.search(block)
num_m = GRID_NUM_RE.search(block)
item_name = name_m.group(1).strip() if name_m else slug
item_num = f" #{num_m.group(1)}" if num_m else ""
name = f"{item_name}{item_num}"
status_m = GRID_STATUS_RE.search(block)
status: str | None = status_m.group(1).strip() if status_m else None
price_m = GRID_PRICE_RE.search(block)
price: str | None = None
if price_m:
raw_price = price_m.group(1).strip().replace(",", "")
try:
price = f"{float(raw_price):.2f}"
except ValueError:
price = raw_price
time_m = GRID_DATETIME_RE.search(block)
date: str | None = time_m.group(1) if time_m else None
items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date})
# Pagination offset from data-next-offset attribute
next_offset_m = re.search(r'data-next-offset="(\d+)"', html)
next_offset = int(next_offset_m.group(1)) if next_offset_m else None
return items, next_offset
-154
View File
@@ -1,154 +0,0 @@
from __future__ import annotations
import asyncio
import random
import re
from typing import Any, cast
import httpx
from pyfragment.types import FragmentPageError, ParseError, VerificationError
from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
def make_headers(page_url: str = FRAGMENT_BASE_URL) -> dict[str, str]:
return {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url}
async def get_fragment_hash(
cookies: dict[str, Any],
headers: dict[str, str],
page_url: str,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
"""Fetch the API hash from a Fragment page.
Fragment embeds a short-lived hash in each page's HTML that must be
included in every subsequent API request. This function loads the page
as a real browser navigation (not XHR) so Fragment returns full HTML.
Args:
cookies: Active Fragment session cookies.
headers: Base headers for the relevant Fragment page.
page_url: URL of the Fragment page to fetch the hash from.
timeout: HTTP request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``.
Returns:
Lowercase hex hash string.
Raises:
FragmentPageError: If the page returns a non-200 status or the hash
is not found in the response HTML.
"""
page_headers = {
k: v
for k, v in headers.items()
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
}
page_headers.update(
{
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"referer": f"{FRAGMENT_BASE_URL}/",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"upgrade-insecure-requests": "1",
}
)
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
response = await session.get(page_url, headers=page_headers)
if response.status_code != 200:
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
if not match:
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
return match.group(1)
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
"""Parse a Fragment API JSON response.
Args:
response: The HTTP response object.
context: Human-readable name of the API method, used in error messages.
Returns:
Parsed response as a dict.
Raises:
ParseError: If the response body cannot be decoded as JSON.
"""
try:
return cast(dict[str, Any], response.json())
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
async def fragment_request(
session: httpx.AsyncClient,
fragment_hash: str,
headers: dict[str, str],
data: dict[str, Any],
) -> dict[str, Any]:
"""POST a single request to the Fragment API.
Builds the ``/api?hash=`` URL, sends the request, and returns the
parsed JSON body. Use this for every API method call — search,
init, state updates, etc.
Args:
session: Active httpx session with Fragment cookies.
fragment_hash: Short-lived hash from the Fragment page HTML.
headers: Page-specific HTTP headers.
data: Form data payload; must include a ``"method"`` key.
Returns:
Parsed API response as a dict.
"""
for attempt in range(3):
resp = await session.post(
f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}",
headers=headers,
data=data,
)
if resp.status_code == 429 and attempt < 2:
await asyncio.sleep(1 + attempt + random.uniform(0, 0.5))
continue
if resp.status_code != 200:
raise FragmentPageError(
FragmentPageError.BAD_STATUS.format(status=resp.status_code, url=f"{FRAGMENT_BASE_URL}/api")
)
return parse_json_response(resp, data.get("method", "request"))
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
async def execute_transaction_request(
session: httpx.AsyncClient,
headers: dict[str, str],
tx_data: dict[str, Any],
fragment_hash: str,
) -> dict[str, Any]:
"""Post a transaction request to the Fragment API.
Args:
session: Active httpx session with Fragment cookies.
headers: Page-specific HTTP headers.
tx_data: Form data payload for the API method.
fragment_hash: Short-lived hash from the Fragment page.
Returns:
Parsed API response dict containing transaction data.
Raises:
VerificationError: If Fragment requires KYC verification.
ParseError: If the response cannot be parsed.
"""
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
return transaction
-149
View File
@@ -1,149 +0,0 @@
from __future__ import annotations
import asyncio
import base64
import random
import ssl
from typing import TYPE_CHECKING, Any
from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError
from pyfragment.types import TransactionError, WalletError, WalletInfo
from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES
from pyfragment.utils.decoder import clean_decode
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
async def process_transaction(client: FragmentClient, transaction_data: dict[str, Any]) -> str:
"""Sign and broadcast a Fragment transaction to the TON network.
Validates the payload structure, checks the wallet balance, decodes the
on-chain comment, and calls ``wallet.transfer``.
Args:
client: Authenticated :class:`FragmentClient` instance.
transaction_data: Raw transaction dict from ``execute_transaction_request``.
Returns:
Normalised transaction hash string.
Raises:
TransactionError: If the payload is malformed or the broadcast fails.
WalletError: If the wallet balance is too low or cannot be fetched.
"""
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
raise TransactionError(TransactionError.INVALID_PAYLOAD)
message = transaction_data["transaction"]["messages"][0]
amount_ton = int(message["amount"]) / 1_000_000_000
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
# Check balance covers transaction amount + gas reserve
try:
await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000
required = amount_ton + MIN_TON_BALANCE
if balance_ton < required:
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required, gas=MIN_TON_BALANCE))
except WalletError:
raise
except Exception as exc:
raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc
try:
payload = clean_decode(message["payload"])
for attempt in range(3):
try:
result = await wallet.transfer(
destination=message["address"],
amount=int(message["amount"]), # nanotons, not TON
body=payload,
)
return str(result.normalized_hash)
except ProviderResponseError as exc:
if exc.code == 429 and attempt == 0:
await asyncio.sleep(1 + random.uniform(0, 0.5))
continue
if exc.code == 406 and "seqno" in str(exc).lower():
# Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry
if attempt < 2:
await asyncio.sleep(2 + random.uniform(0, 1))
continue
raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc
raise
except (WalletError, TransactionError):
raise
except Exception as exc:
cause: BaseException | None = exc
while cause is not None:
if isinstance(cause, ssl.SSLError):
raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc
cause = cause.__cause__ or cause.__context__
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"""Fetch wallet address, public key, and state-init for the Fragment API.
Fragment requires account info to build each transaction payload. The
returned dict is JSON-serialised and passed as the ``account`` field in
``getBuy*Link`` / ``get*Link`` requests.
Args:
client: Authenticated :class:`FragmentClient` instance.
Returns:
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
Raises:
WalletError: If account info cannot be retrieved.
"""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
boc = wallet.state_init.serialize().to_boc()
return {
"address": wallet.address.to_str(False, False),
"publicKey": pub_key.as_hex,
"chain": "-239",
"walletStateInit": base64.b64encode(boc).decode(),
}
except Exception as exc:
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Return the address, state and balance of the TON wallet.
Args:
client: Authenticated :class:`FragmentClient` instance.
Returns:
:class:`WalletInfo` with ``address``, ``state``, and ``balance`` in TON.
Raises:
WalletError: If the wallet state cannot be fetched.
"""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
await wallet.refresh()
return WalletInfo(
address=wallet.address.to_str(is_user_friendly=True, is_bounceable=False),
state=wallet.state.value,
balance=round(wallet.balance / 1_000_000_000, 4),
)
except Exception as exc:
raise WalletError(WalletError.WALLET_INFO_FAILED.format(exc=exc)) from exc
-91
View File
@@ -1,91 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "pyfragment"
version = "2026.2.1"
description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
authors = [{ name = "bohd4nx" }]
keywords = [
"fragment",
"fragment-api",
"telegram",
"telegram-stars",
"telegram-premium",
"ton",
"tonkeeper",
"tonapi",
"crypto",
"blockchain",
"web3",
"giveaway",
"anonymous-number",
"username",
"nft",
"async",
"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.25", "rookiepy>=0.5.6", "tonutils>=2.0.1"]
[project.optional-dependencies]
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
[project.urls]
Homepage = "https://github.com/bohd4nx/pyfragment"
Repository = "https://github.com/bohd4nx/pyfragment"
Issues = "https://github.com/bohd4nx/pyfragment/issues"
Changelog = "https://github.com/bohd4nx/pyfragment/blob/master/CHANGELOG.md"
[tool.hatch.build.targets.wheel]
packages = ["pyfragment"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["[0-9][0-9][0-9]_test_*.py"]
asyncio_mode = "auto"
addopts = "-v --tb=short"
[tool.ruff]
line-length = 128
target-version = "py312"
[tool.ruff.lint]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
select = ["E", "F", "W", "I", "UP"]
# E501 — line too long (covered by line-length above)
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
ignore = ["E501", "UP017"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"]
"systests/*" = ["E402"]
[tool.mypy]
python_version = "3.10"
strict = true
exclude = ["^systests/", "^examples/"]
-46
View File
@@ -1,46 +0,0 @@
"""Tests for clean_decode() — TON BOC payload decoding."""
import re
import pytest
from pyfragment.types import ParseError
from pyfragment.utils.decoder import clean_decode
PAYLOADS = [
pytest.param(
"te6ccgEBAgEALwABTgAAAAAxMDAwMDAwIFRlbGVncmFtIFN0YXJzIAoKUmVmI1RQb01wegEABkM3ZQ",
id="stars",
),
pytest.param(
"te6ccgEBAgEANAABTgAAAABUZWxlZ3JhbSBQcmVtaXVtIGZvciAxIHllYXIgCgpSZWYjcgEAEE9OQnM2cmNt",
id="premium",
),
pytest.param(
"te6ccgEBAgEAMAABTgAAAABUZWxlZ3JhbSBhY2NvdW50IHRvcCB1cCAKClJlZiNrMXpDRQEACFkxd3g",
id="topup",
),
]
# Decode valid payload tests
@pytest.mark.parametrize("payload", PAYLOADS)
def test_decode_payload(payload: str) -> 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}"
# Edge case tests
def test_empty_payload_returns_empty_string() -> None:
assert clean_decode("") == ""
def test_invalid_payload_raises_parse_error() -> None:
with pytest.raises(ParseError):
clean_decode("!!!not-valid-base64!!!")
-119
View File
@@ -1,119 +0,0 @@
"""Unit tests for FragmentClient — initialization, validation, and cookie parsing."""
import json
import pytest
from pyfragment import FragmentClient
from pyfragment.types import ConfigurationError, CookieError
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
# Client init tests
def test_valid_init() -> None:
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
assert client.seed == VALID_SEED.strip()
assert client.api_key == VALID_API_KEY
assert client.wallet_version == "V5R1"
# Wallet version tests
def test_wallet_version_v4r2() -> None:
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V4R2")
assert client.wallet_version == "V4R2"
def test_wallet_version_is_case_insensitive() -> None:
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="v5r1")
assert client.wallet_version == "V5R1"
def test_unsupported_wallet_version_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2")
# Seed and mnemonic validation tests
def test_missing_seed_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
def test_whitespace_only_seed_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES)
def test_invalid_mnemonic_length_raises() -> None:
bad_seed = " ".join(["word"] * 23)
with pytest.raises(ConfigurationError):
FragmentClient(seed=bad_seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
def test_valid_mnemonic_lengths() -> None:
for length in (12, 18, 24):
seed = " ".join(["abandon"] * (length - 1) + ["about"])
client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
assert len(client.seed.split()) == length
# API key validation tests
def test_missing_api_key_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
def test_short_api_key_raises() -> None:
with pytest.raises(ConfigurationError):
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
# Cookie validation tests
def test_cookies_as_json_string() -> None:
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=json.dumps(VALID_COOKIES))
assert client.cookies == VALID_COOKIES
def test_invalid_cookies_json_raises() -> None:
with pytest.raises(CookieError):
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies="{not valid json}")
def test_missing_cookie_key_raises() -> None:
with pytest.raises(CookieError):
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies={"stel_ssid": "x"})
def test_empty_cookie_value_raises() -> None:
bad = {**VALID_COOKIES, "stel_token": ""}
with pytest.raises(CookieError):
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad)
def test_whitespace_cookie_value_raises() -> None:
bad = {**VALID_COOKIES, "stel_ton_token": " "}
with pytest.raises(CookieError):
FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad)
def test_repr() -> None:
client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
r = repr(client)
assert "FragmentClient" in r
assert "V5R1" in r
assert "4 keys" in r
@pytest.mark.asyncio
async def test_async_context_manager() -> None:
async with FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) as client:
assert isinstance(client, FragmentClient)
-140
View File
@@ -1,140 +0,0 @@
"""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
import pytest
from tonutils.exceptions import ProviderResponseError
from pyfragment.types import TransactionError, WalletError
from pyfragment.utils.wallet import process_transaction
from tests.shared import VALID_SEED
def _provider_error(code: int, message: str = "error") -> ProviderResponseError:
return ProviderResponseError(code=code, message=message, endpoint="api.tonapi.io")
TRANSACTION_DATA = {
"transaction": {
"messages": [
{
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
"amount": "500000000", # 0.5 TON
"payload": "",
}
]
}
}
def _make_client() -> MagicMock:
client = MagicMock()
client.api_key = "test_key"
client.seed = VALID_SEED.split()
client.wallet_version = "V5R1"
return client
def _make_wallet(balance_nanotons: int) -> MagicMock:
wallet = MagicMock()
wallet.refresh = AsyncMock()
wallet.balance = balance_nanotons
wallet.transfer = AsyncMock(return_value=MagicMock(normalized_hash="abc123"))
return wallet
@contextmanager
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,
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
yield
# Balance threshold tests
@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
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"
wallet.transfer.assert_called_once()
@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
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
wallet.transfer.assert_not_called()
@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
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"
@pytest.mark.asyncio
async def test_one_nanoton_below_minimum_raises() -> None:
wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton below threshold
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="required"):
await process_transaction(_make_client(), TRANSACTION_DATA)
# Error handling tests
@pytest.mark.asyncio
async def test_invalid_payload_raises() -> None:
with pytest.raises(TransactionError):
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)
wallet.refresh = AsyncMock(side_effect=RuntimeError("network timeout"))
with _patch_wallet(wallet):
with pytest.raises(WalletError, match="balance"):
await process_transaction(_make_client(), TRANSACTION_DATA)
wallet.transfer.assert_not_called()
@pytest.mark.asyncio
async def test_rate_limit_retries_and_succeeds() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000)
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
assert wallet.transfer.call_count == 2
@pytest.mark.asyncio
async def test_duplicate_seqno_raises_after_retries() -> None:
wallet = _make_wallet(balance_nanotons=1_000_000_000)
err = _provider_error(406, "Duplicate msg_seqno")
wallet.transfer = AsyncMock(side_effect=[err, err, err])
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""):
with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3
-142
View File
@@ -1,142 +0,0 @@
"""Unit tests for Stars methods — purchase_stars and giveaway_stars."""
import importlib
from unittest.mock import AsyncMock, patch
import pytest
_purchase_stars_mod = importlib.import_module("pyfragment.methods.purchase_stars")
_giveaway_stars_mod = importlib.import_module("pyfragment.methods.giveaway_stars")
from pyfragment import FragmentClient
from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Stars purchase validation tests
@pytest.mark.asyncio
async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.purchase_stars("@user", amount=49)
@pytest.mark.asyncio
async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.purchase_stars("@user", amount=1_000_001)
@pytest.mark.asyncio
async def test_purchase_stars_float_amount(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
# Stars purchase mocked tests
@pytest.mark.asyncio
async def test_purchase_stars_success(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.purchase_stars("@user", amount=500)
assert isinstance(result, StarsResult)
assert result.transaction_id == FAKE_TX_HASH
assert result.username == "@user"
assert result.amount == 500
@pytest.mark.asyncio
async def test_purchase_stars_user_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.purchase_stars("@ghost", amount=500)
# Stars giveaway validation tests
@pytest.mark.asyncio
async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=0, amount=500)
@pytest.mark.asyncio
async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=6, amount=500)
@pytest.mark.asyncio
async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=1, amount=499)
@pytest.mark.asyncio
async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=1, amount=1_000_001)
@pytest.mark.asyncio
async def test_giveaway_stars_float_winners(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=1.5, amount=500) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_giveaway_stars_float_amount(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type]
# Stars giveaway mocked tests
@pytest.mark.asyncio
async def test_giveaway_stars_success(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_giveaway_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.giveaway_stars("@channel", winners=3, amount=1000)
assert isinstance(result, StarsGiveawayResult)
assert result.transaction_id == FAKE_TX_HASH
assert result.channel == "@channel"
assert result.winners == 3
assert result.amount == 1000
@pytest.mark.asyncio
async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.giveaway_stars("@ghost", winners=1, amount=500)
-125
View File
@@ -1,125 +0,0 @@
"""Unit tests for Premium methods — purchase_premium and giveaway_premium."""
import importlib
from unittest.mock import AsyncMock, patch
import pytest
_purchase_premium_mod = importlib.import_module("pyfragment.methods.purchase_premium")
_giveaway_premium_mod = importlib.import_module("pyfragment.methods.giveaway_premium")
from pyfragment import FragmentClient
from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Premium purchase validation tests
@pytest.mark.asyncio
async def test_purchase_premium_invalid_months(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.purchase_premium("@user", months=5)
@pytest.mark.asyncio
async def test_purchase_premium_months_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.purchase_premium("@user", months=0)
# Premium purchase mocked tests
@pytest.mark.asyncio
async def test_purchase_premium_success(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{}, # updatePremiumState
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_purchase_premium_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.purchase_premium("@user", months=3)
assert isinstance(result, PremiumResult)
assert result.transaction_id == FAKE_TX_HASH
assert result.username == "@user"
assert result.amount == 3
@pytest.mark.asyncio
async def test_purchase_premium_user_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.purchase_premium("@ghost", months=3)
# Premium giveaway validation tests
@pytest.mark.asyncio
async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_premium("@channel", winners=0, months=3)
@pytest.mark.asyncio
async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_premium("@channel", winners=24_001, months=3)
@pytest.mark.asyncio
async def test_giveaway_premium_float_winners(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_premium("@channel", winners=2.5, months=3) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.giveaway_premium("@channel", winners=10, months=5)
# Premium giveaway mocked tests
@pytest.mark.asyncio
async def test_giveaway_premium_success(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_giveaway_premium_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.giveaway_premium("@channel", winners=10, months=3)
assert isinstance(result, PremiumGiveawayResult)
assert result.transaction_id == FAKE_TX_HASH
assert result.channel == "@channel"
assert result.winners == 10
assert result.amount == 3
@pytest.mark.asyncio
async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"found": {}})):
with pytest.raises(UserNotFoundError):
await client.giveaway_premium("@ghost", winners=1, months=3)
-76
View File
@@ -1,76 +0,0 @@
"""Unit tests for topup_ton — TON Ads balance top-up."""
import importlib
from unittest.mock import AsyncMock, patch
import pytest
_topup_ton_mod = importlib.import_module("pyfragment.methods.topup_ton")
from pyfragment import FragmentClient
from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Topup TON validation tests
@pytest.mark.asyncio
async def test_topup_ton_amount_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=0)
@pytest.mark.asyncio
async def test_topup_ton_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=1_000_000_001)
@pytest.mark.asyncio
async def test_topup_ton_float_amount(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type]
# Topup TON mocked tests
@pytest.mark.asyncio
async def test_topup_ton_success(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsTopupState
{"found": {"recipient": FAKE_RECIPIENT}},
{"req_id": FAKE_REQ_ID},
FAKE_TRANSACTION,
]
),
),
patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.topup_ton("@user", amount=10)
assert isinstance(result, AdsTopupResult)
assert result.transaction_id == FAKE_TX_HASH
assert result.username == "@user"
assert result.amount == 10
@pytest.mark.asyncio
async def test_topup_ton_user_not_found(client: FragmentClient) -> None:
with patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsTopupState
{"found": {}},
]
),
):
with pytest.raises(UserNotFoundError):
await client.topup_ton("@ghost", amount=10)
-56
View File
@@ -1,56 +0,0 @@
"""Unit tests for get_wallet() — wallet address and TON balance lookup."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pyfragment import FragmentClient, WalletInfo
from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON
# Wallet mocked tests
@pytest.mark.asyncio
async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
mock_wallet = MagicMock()
mock_wallet.refresh = AsyncMock()
mock_wallet.balance = FAKE_BALANCE_NANOTON
mock_wallet.state = MagicMock(value="active")
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
result = await client.get_wallet()
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)
@pytest.mark.asyncio
async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
mock_wallet = MagicMock()
mock_wallet.refresh = AsyncMock()
mock_wallet.balance = 0
mock_wallet.state = MagicMock(value="uninit")
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None)
result = await client.get_wallet()
assert result.balance == 0.0
assert result.state == "uninit"
-82
View File
@@ -1,82 +0,0 @@
"""Unit tests for FragmentClient.call() — raw Fragment API access."""
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
@pytest.mark.asyncio
async def test_call_returns_api_response(client: FragmentClient) -> None:
with (
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
):
result = await client.call("anyMethod", {"key": "value"})
assert result == FAKE_RESPONSE
@pytest.mark.asyncio
async def test_call_default_page_url(client: FragmentClient) -> None:
"""call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL)."""
with (
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
):
result = await client.call("anyMethod")
assert result == FAKE_RESPONSE
@pytest.mark.asyncio
async def test_call_no_data(client: FragmentClient) -> None:
"""call() with no extra data passes only the method field."""
mock_request = AsyncMock(return_value={})
with (
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.client.fragment_request", mock_request),
):
await client.call("anyMethod")
_, _, _, sent_data = mock_request.call_args.args
assert sent_data == {"method": "anyMethod"}
@pytest.mark.asyncio
async def test_call_merges_extra_data(client: FragmentClient) -> None:
"""call() merges caller-supplied data with the method field."""
mock_request = AsyncMock(return_value={})
with (
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
patch("pyfragment.client.fragment_request", mock_request),
):
await client.call("anyMethod", {"key": "value", "num": 7})
_, _, _, 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"})
-128
View File
@@ -1,128 +0,0 @@
"""Unit tests for anonymous number methods — login codes and session management."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult
from tests.shared import FAKE_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH
# get_login_code mocked tests
@pytest.mark.asyncio
async def test_get_login_code_returns_code(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_WITH_CODE})):
result = await client.get_login_code("+1234567890")
assert isinstance(result, LoginCodeResult)
assert result.number == "+1234567890"
assert result.code == "12345"
assert result.active_sessions == 2 # 2 <tr> in the HTML
@pytest.mark.asyncio
async def test_get_login_code_no_pending_code(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_NO_CODE})):
result = await client.get_login_code("1234567890")
assert result.code is None
assert result.active_sessions == 1
@pytest.mark.asyncio
async def test_get_login_code_no_html_returns_none(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={})):
result = await client.get_login_code("+1234567890")
assert result.code is None
assert result.active_sessions == 0
# terminate_sessions mocked tests
@pytest.mark.asyncio
async def test_terminate_sessions_success(client: FragmentClient) -> None:
with patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"terminate_hash": FAKE_TERMINATE_HASH}, # step 1: confirmation
{"msg": "All sessions terminated"}, # step 2: confirmed
]
),
):
result = await client.terminate_sessions("+1234567890")
assert isinstance(result, TerminateSessionsResult)
assert result.number == "+1234567890"
assert result.message == "All sessions terminated"
@pytest.mark.asyncio
async def test_terminate_sessions_not_owned_raises(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={})):
with pytest.raises(AnonymousNumberError, match="not associated"):
await client.terminate_sessions("+1234567890")
@pytest.mark.asyncio
async def test_terminate_sessions_api_error_raises(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"error": "SESSION_ALREADY_TERMINATED"})):
with pytest.raises(AnonymousNumberError, match="SESSION_ALREADY_TERMINATED"):
await client.terminate_sessions("+1234567890")
@pytest.mark.asyncio
async def test_terminate_sessions_confirm_error_raises(client: FragmentClient) -> None:
with patch.object(
client,
"call",
AsyncMock(
side_effect=[
{"terminate_hash": FAKE_TERMINATE_HASH},
{"error": "INTERNAL_ERROR"},
]
),
):
with pytest.raises(AnonymousNumberError, match="INTERNAL_ERROR"):
await client.terminate_sessions("+1234567890")
# toggle_login_codes mocked tests
@pytest.mark.asyncio
async def test_toggle_login_codes_enable(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True})
with patch.object(client, "call", mock_call):
await client.toggle_login_codes("+1234567890", can_receive=True)
call_data = mock_call.call_args[0][1]
assert call_data["can_receive"] == 1
@pytest.mark.asyncio
async def test_toggle_login_codes_disable(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True})
with patch.object(client, "call", mock_call):
await client.toggle_login_codes("+1234567890", can_receive=False)
call_data = mock_call.call_args[0][1]
assert call_data["can_receive"] == 0
# strip_plus mocked tests
@pytest.mark.asyncio
async def test_get_login_code_strips_plus(client: FragmentClient) -> None:
"""Number passed with '+' is stripped before the API call."""
mock_call = AsyncMock(return_value={})
with patch.object(client, "call", mock_call):
await client.get_login_code("+1234567890")
call_data = mock_call.call_args[0][1]
assert call_data["number"] == "1234567890"
-58
View File
@@ -1,58 +0,0 @@
"""Unit tests for recharge_ads — self-service Telegram Ads recharge."""
import importlib
from unittest.mock import AsyncMock, patch
import pytest
_recharge_ads_mod = importlib.import_module("pyfragment.methods.recharge_ads")
from pyfragment import FragmentClient
from pyfragment.types import AdsRechargeResult, ConfigurationError
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# recharge_ads validation tests
@pytest.mark.asyncio
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0)
@pytest.mark.asyncio
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001)
@pytest.mark.asyncio
async def test_recharge_ads_float_amount(client: FragmentClient) -> None:
with pytest.raises(ConfigurationError):
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=5.5) # type: ignore[arg-type]
# recharge_ads mocked tests
@pytest.mark.asyncio
async def test_recharge_ads_success(client: FragmentClient) -> None:
with (
patch.object(
client,
"call",
AsyncMock(
side_effect=[
{}, # updateAdsState
{"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest
FAKE_TRANSACTION, # getAdsRechargeLink
]
),
),
patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
patch.object(_recharge_ads_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
):
result = await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
assert isinstance(result, AdsRechargeResult)
assert result.transaction_id == FAKE_TX_HASH
assert result.amount == 10
-88
View File
@@ -1,88 +0,0 @@
"""Unit tests for search_usernames — Fragment marketplace username search."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import UsernamesResult
FAKE_HTML = """
<tr class="tm-row-selectable">
<td><a href="/username/coolname" class="table-cell">
<div class="table-cell-value tm-value">@coolname</div>
<div class="table-cell-status-thin thin-only tm-status-avail">On auction</div>
</a></td>
<td><div class="table-cell-value tm-value icon-before icon-ton">5</div>
<time datetime="2026-06-01T12:00:00+00:00" data-relative="text">2 days</time>
</td>
<td><div class="table-cell-value tm-value tm-status-avail">On auction</div></td>
</tr>
"""
# search_usernames result parsing tests
@pytest.mark.asyncio
async def test_search_usernames_basic(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
result = await client.search_usernames("coolname")
assert isinstance(result, UsernamesResult)
assert len(result.items) == 1
assert result.items[0]["slug"] == "username/coolname"
assert result.items[0]["name"] == "@coolname"
assert result.items[0]["date"] == "2026-06-01T12:00:00+00:00"
assert result.next_offset_id is None
@pytest.mark.asyncio
async def test_search_usernames_empty_html(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
result = await client.search_usernames("zzz_no_results")
assert isinstance(result, UsernamesResult)
assert result.items == []
assert result.next_offset_id is None
# search_usernames parameter forwarding tests
@pytest.mark.asyncio
async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_usernames("durov", sort="price_desc", filter="auction")
assert isinstance(result, UsernamesResult)
call_data = mock_call.call_args[0][1]
assert call_data["type"] == "usernames"
assert call_data["sort"] == "price_desc"
assert call_data["filter"] == "auction"
assert call_data["query"] == "durov"
@pytest.mark.asyncio
async def test_search_usernames_with_offset_id(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"})
with patch.object(client, "call", mock_call):
result = await client.search_usernames("durov", offset_id="offset_10")
assert isinstance(result, UsernamesResult)
assert result.next_offset_id == "offset_99"
call_data = mock_call.call_args[0][1]
assert call_data["offset_id"] == "offset_10"
@pytest.mark.asyncio
async def test_search_usernames_default_query(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_usernames()
assert isinstance(result, UsernamesResult)
call_data = mock_call.call_args[0][1]
assert call_data["query"] == ""
assert call_data["type"] == "usernames"
-88
View File
@@ -1,88 +0,0 @@
"""Unit tests for search_numbers — Fragment marketplace number search."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import NumbersResult
FAKE_HTML = """
<tr class="tm-row-selectable">
<td><a href="/number/8880000888" class="table-cell">
<div class="table-cell-value tm-value">+888 0000 888</div>
<div class="table-cell-status-thin thin-only tm-status-avail">For sale</div>
</a></td>
<td><div class="table-cell-value tm-value icon-before icon-ton">150</div>
<time datetime="2026-05-15T10:00:00+00:00" data-relative="short-text">May 15</time>
</td>
<td><div class="table-cell-value tm-value tm-status-avail">For sale</div></td>
</tr>
"""
# search_numbers result parsing tests
@pytest.mark.asyncio
async def test_search_numbers_basic(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})):
result = await client.search_numbers("888")
assert isinstance(result, NumbersResult)
assert len(result.items) == 1
assert result.items[0]["slug"] == "number/8880000888"
assert result.items[0]["name"] == "+888 0000 888"
assert result.items[0]["date"] == "2026-05-15T10:00:00+00:00"
assert result.next_offset_id is None
@pytest.mark.asyncio
async def test_search_numbers_empty_html(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
result = await client.search_numbers("zzz_no_results")
assert isinstance(result, NumbersResult)
assert result.items == []
assert result.next_offset_id is None
# search_numbers parameter forwarding tests
@pytest.mark.asyncio
async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_numbers("888", sort="price_asc", filter="sale")
assert isinstance(result, NumbersResult)
call_data = mock_call.call_args[0][1]
assert call_data["type"] == "numbers"
assert call_data["sort"] == "price_asc"
assert call_data["filter"] == "sale"
assert call_data["query"] == "888"
@pytest.mark.asyncio
async def test_search_numbers_with_offset_id(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"})
with patch.object(client, "call", mock_call):
result = await client.search_numbers("888", offset_id="offset_50")
assert isinstance(result, NumbersResult)
assert result.next_offset_id == "offset_50"
call_data = mock_call.call_args[0][1]
assert call_data["offset_id"] == "offset_50"
@pytest.mark.asyncio
async def test_search_numbers_default_query(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_numbers()
assert isinstance(result, NumbersResult)
call_data = mock_call.call_args[0][1]
assert call_data["query"] == ""
assert call_data["type"] == "numbers"
-155
View File
@@ -1,155 +0,0 @@
"""Unit tests for search_gifts — Fragment gifts marketplace search."""
from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import GiftsResult
FAKE_GIFTS_HTML = """
<div class="tm-catalog-grid">
<a href="/gift/plushpepe-1821?collection=all" class="tm-grid-item">
<div class="tm-grid-item-thumb">
<img src="https://nft.fragment.com/gift/plushpepe-1821.medium.jpg" class="tm-grid-thumb"/>
</div>
<div class="tm-grid-item-content">
<div class="tm-grid-item-name wide-only">
<span class="item-name">Plush Pepe</span>
<span class="item-num">&nbsp;#1821</span>
</div>
<div class="tm-grid-item-desc wide-only">
<time datetime="2026-02-05T14:41:27+00:00" class="short">Feb 5 at 16:41</time>
</div>
<div class="tm-grid-item-values">
<div class="tm-grid-item-value tm-value icon-before icon-ton">88,888</div>
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
</div>
</div>
</a>
<a href="/gift/swisswatch-7799?collection=all" class="tm-grid-item">
<div class="tm-grid-item-content">
<div class="tm-grid-item-name wide-only">
<span class="item-name">Swiss Watch</span>
<span class="item-num">&nbsp;#7799</span>
</div>
<div class="tm-grid-item-desc wide-only">
<time datetime="2026-01-10T04:52:59+00:00" class="short">Jan 10 at 06:52</time>
</div>
<div class="tm-grid-item-values">
<div class="tm-grid-item-value tm-value icon-before icon-ton">13,588</div>
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
</div>
</div>
</a>
<a class="tm-catalog-grid-more js-load-more" data-next-offset="60">Show more</a>
</div>
"""
# search_gifts result parsing tests
@pytest.mark.asyncio
async def test_search_gifts_basic(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})):
result = await client.search_gifts()
assert isinstance(result, GiftsResult)
assert len(result.items) == 2
assert result.items[0]["slug"] == "gift/plushpepe-1821"
assert result.items[0]["name"] == "Plush Pepe #1821"
assert result.items[0]["status"] == "Sold"
assert result.items[0]["price"] == "88888.00"
assert result.items[0]["date"] == "2026-02-05T14:41:27+00:00"
assert result.items[1]["slug"] == "gift/swisswatch-7799"
assert result.items[1]["price"] == "13588.00"
assert result.next_offset == 60
@pytest.mark.asyncio
async def test_search_gifts_empty(client: FragmentClient) -> None:
with patch.object(client, "call", AsyncMock(return_value={"ok": True})):
result = await client.search_gifts(query="zzz_no_results")
assert isinstance(result, GiftsResult)
assert result.items == []
assert result.next_offset is None
# search_gifts parameter forwarding tests
@pytest.mark.asyncio
async def test_search_gifts_with_collection_and_sort(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_gifts(collection="plushpepe", sort="price_desc", filter="sold")
assert isinstance(result, GiftsResult)
call_data = mock_call.call_args[0][1]
assert call_data["collection"] == "plushpepe"
assert call_data["sort"] == "price_desc"
assert call_data["filter"] == "sold"
assert call_data["type"] == "gifts"
@pytest.mark.asyncio
async def test_search_gifts_with_offset(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_gifts(offset=60)
assert isinstance(result, GiftsResult)
call_data = mock_call.call_args[0][1]
assert call_data["offset"] == 60
@pytest.mark.asyncio
async def test_search_gifts_with_view(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_gifts(collection="artisanbrick", view="Model")
assert isinstance(result, GiftsResult)
call_data = mock_call.call_args[0][1]
assert call_data["view"] == "Model"
assert call_data["collection"] == "artisanbrick"
@pytest.mark.asyncio
async def test_search_gifts_with_attr(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_gifts(
collection="artisanbrick",
sort="listed",
filter="auction",
attr={
"Model": ["Delicate Wash", "Foosball", "Chocolate"],
"Backdrop": ["Celtic Blue", "Carrot Juice", "Orange"],
"Symbol": ["Crystal Ball", "Tetsubin", "Acorn"],
},
)
assert isinstance(result, GiftsResult)
call_data = mock_call.call_args[0][1]
assert call_data["attr[Model]"] == ["Delicate Wash", "Foosball", "Chocolate"]
assert call_data["attr[Backdrop]"] == ["Celtic Blue", "Carrot Juice", "Orange"]
assert call_data["attr[Symbol]"] == ["Crystal Ball", "Tetsubin", "Acorn"]
assert call_data["collection"] == "artisanbrick"
assert call_data["sort"] == "listed"
assert call_data["filter"] == "auction"
assert call_data["type"] == "gifts"
@pytest.mark.asyncio
async def test_search_gifts_attr_not_in_data_when_none(client: FragmentClient) -> None:
mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})
with patch.object(client, "call", mock_call):
result = await client.search_gifts()
assert isinstance(result, GiftsResult)
call_data = mock_call.call_args[0][1]
assert "view" not in call_data
assert not any(k.startswith("attr[") for k in call_data)
-132
View File
@@ -1,132 +0,0 @@
"""Unit tests for get_cookies_from_browser() — browser cookie extraction helper."""
from unittest.mock import MagicMock, patch
import pytest
from pyfragment.types import CookieError
from pyfragment.types.constants import REQUIRED_COOKIE_KEYS
from pyfragment.utils import get_cookies_from_browser
FAKE_JAR = [
{"name": "stel_ssid", "value": "abc123", "domain": "fragment.com", "expires": "2027-04-03T20:52:16.375Z"},
{"name": "stel_dt", "value": "-120", "domain": "fragment.com"},
{"name": "stel_token", "value": "tok_xyz", "domain": "fragment.com"},
{"name": "stel_ton_token", "value": "ton_xyz", "domain": "fragment.com"},
{"name": "unrelated", "value": "noise", "domain": "fragment.com"},
]
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
PATCH = "pyfragment.utils.cookies.rookiepy"
# unsupported browser tests
def test_unsupported_browser_raises() -> None:
with pytest.raises(CookieError, match="Unsupported browser"):
get_cookies_from_browser("internet_explorer")
# successful extraction tests
def test_returns_required_keys_only() -> None:
with patch(PATCH, _mock_rookiepy()):
result = get_cookies_from_browser("chrome")
assert set(result.cookies.keys()) == set(REQUIRED_COOKIE_KEYS)
assert result.cookies["stel_ssid"] == "abc123"
assert result.cookies["stel_dt"] == "-120"
assert result.cookies["stel_token"] == "tok_xyz"
assert result.cookies["stel_ton_token"] == "ton_xyz"
assert "unrelated" not in result.cookies
def test_returns_expiry() -> None:
with patch(PATCH, _mock_rookiepy()):
result = get_cookies_from_browser("chrome")
assert result.expires == "2027-04-03T20:52:16.375000+00:00"
def test_returns_none_expiry_when_missing() -> None:
jar = [{"name": k, "value": "v"} for k in REQUIRED_COOKIE_KEYS]
with patch(PATCH, _mock_rookiepy(jar)):
result = get_cookies_from_browser("chrome")
assert result.expires is None
def test_default_browser_is_chrome() -> None:
mock_rp = _mock_rookiepy()
with patch(PATCH, mock_rp):
get_cookies_from_browser()
mock_rp.chrome.assert_called_once_with(["fragment.com"])
def test_browser_name_is_case_insensitive() -> None:
mock_rp = _mock_rookiepy()
with patch(PATCH, mock_rp):
result = get_cookies_from_browser("Chrome")
assert result.cookies["stel_ssid"] == "abc123"
# missing cookies tests
def test_missing_cookies_raises() -> None:
partial_jar = [
{"name": "stel_ssid", "value": "abc123"},
{"name": "stel_dt", "value": "-120"},
# stel_token and stel_ton_token missing
]
with patch(PATCH, _mock_rookiepy(partial_jar)):
with pytest.raises(CookieError, match="Fragment cookies not found in chrome"):
get_cookies_from_browser("chrome")
def test_empty_cookie_value_treated_as_missing() -> None:
jar_with_empty = [
{"name": "stel_ssid", "value": "abc123"},
{"name": "stel_dt", "value": ""}, # empty — should be treated as missing
{"name": "stel_token", "value": "tok_xyz"},
{"name": "stel_ton_token", "value": "ton_xyz"},
]
with patch(PATCH, _mock_rookiepy(jar_with_empty)):
with pytest.raises(CookieError, match="Fragment cookies not found in chrome"):
get_cookies_from_browser("chrome")
# expired cookie tests
def test_expired_cookie_raises() -> None:
expired_jar = [
{"name": "stel_ssid", "value": "abc123", "expires": "2020-01-01T00:00:00.000Z"},
{"name": "stel_dt", "value": "-120"},
{"name": "stel_token", "value": "tok_xyz"},
{"name": "stel_ton_token", "value": "ton_xyz"},
]
with patch(PATCH, _mock_rookiepy(expired_jar)):
with pytest.raises(CookieError, match="expired"):
get_cookies_from_browser("chrome")
# read failure tests
def test_browser_read_error_raises() -> None:
mock_rp = MagicMock()
mock_rp.chrome.side_effect = PermissionError("locked")
with patch(PATCH, mock_rp):
with pytest.raises(CookieError, match="Failed to read chrome cookies"):
get_cookies_from_browser("chrome")
View File
-32
View File
@@ -1,32 +0,0 @@
import json
import os
from typing import cast
import pytest
import pyfragment.methods.giveaway_premium # noqa: F401
import pyfragment.methods.giveaway_stars # noqa: F401
import pyfragment.methods.purchase_premium # noqa: F401
import pyfragment.methods.purchase_stars # noqa: F401
import pyfragment.methods.recharge_ads # noqa: F401
import pyfragment.methods.topup_ton # noqa: F401
from pyfragment import FragmentClient
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
@pytest.fixture
def cookies() -> dict[str, str]:
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
raw = os.environ.get("COOKIES_JSON")
if not raw:
pytest.skip("COOKIES_JSON env var not set")
try:
return cast(dict[str, str], json.loads(raw))
except Exception as exc:
pytest.skip(f"Cookies unavailable — {exc}")
@pytest.fixture
def client() -> FragmentClient:
"""Pre-built FragmentClient with dummy credentials."""
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
-55
View File
@@ -1,55 +0,0 @@
"""Shared test constants for the pyfragment test suite.
pyfragment is an async Python client for the Fragment API — a unified toolkit
to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances,
run giveaways, manage anonymous numbers, and explore the marketplace for usernames,
numbers, and gifts.
"""
from typing import Any
# Credentials and config
VALID_SEED: str = "abandon " * 23 + "about"
VALID_API_KEY: str = "A" * 68
VALID_COOKIES: dict[str, str] = {
"stel_ssid": "x",
"stel_dt": "x",
"stel_token": "x",
"stel_ton_token": "x",
}
# Generic test data
FAKE_HASH: str = "abc123"
FAKE_RECIPIENT: str = "recipient_token"
FAKE_REQ_ID: str = "req_42"
FAKE_TX_HASH: str = "deadbeef" * 8
FAKE_ACCOUNT: dict[str, Any] = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="}
FAKE_TRANSACTION: dict[str, Any] = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}}
# client.call()
FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}}
# get_wallet()
FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5"
FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON
# recharge_ads
FAKE_ADS_ACCOUNT: str = "@mychannel"
# Revenue withdrawals
FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
FAKE_REVENUE_TX: str = "revenue_tx_abc123"
# Anonymous number
FAKE_HTML_WITH_CODE: str = """
<table>
<tr>
<td class="table-cell-value">12345</td>
</tr>
<tr>
<td>session data</td>
</tr>
</table>
"""
FAKE_HTML_NO_CODE: str = "<table><tr><td>no code here</td></tr></table>"
FAKE_TERMINATE_HASH: str = "terminate_hash_abc123"