mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-26 14:51:40 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0504f88947 | |||
| 762a6267f8 | |||
| f7f8982554 | |||
| e0251d5d04 | |||
| cc279a0fde | |||
| 392d8f5587 | |||
| fb31bad450 | |||
| 3d42a8b998 | |||
| 81247b3d36 |
@@ -0,0 +1,5 @@
|
|||||||
|
root: ./docs
|
||||||
|
|
||||||
|
structure:
|
||||||
|
readme: README.md
|
||||||
|
summary: SUMMARY.md
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["**"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["**"]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: Lint & Format
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6.0.2
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v6.2.0
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v7.5.0
|
|
||||||
|
|
||||||
- run: uv pip install --system ".[dev]"
|
|
||||||
|
|
||||||
- run: ruff check .
|
|
||||||
|
|
||||||
- run: black --check . --target-version py312
|
|
||||||
|
|
||||||
test:
|
|
||||||
name: Tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6.0.2
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v6.2.0
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v7.5.0
|
|
||||||
|
|
||||||
- run: uv pip install --system ".[dev]"
|
|
||||||
|
|
||||||
- name: Write cookies.json
|
|
||||||
if: ${{ env.COOKIES_JSON != '' }}
|
|
||||||
run: echo "$COOKIES_JSON" > cookies.json
|
|
||||||
env:
|
|
||||||
COOKIES_JSON: ${{ secrets.COOKIES_JSON }}
|
|
||||||
|
|
||||||
- run: pytest
|
|
||||||
@@ -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
|
||||||
@@ -1,101 +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
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.version.outputs.value }}
|
|
||||||
is-new: ${{ steps.tag.outputs.is-new }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6.0.2
|
|
||||||
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
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6.0.2
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v6.2.0
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- uses: astral-sh/setup-uv@v7.5.0
|
|
||||||
|
|
||||||
- run: uv build
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist/*
|
|
||||||
|
|
||||||
publish:
|
|
||||||
name: Publish to PyPI
|
|
||||||
needs: [version-check, build]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment:
|
|
||||||
name: pypi
|
|
||||||
url: https://pypi.org/project/pyfragment/
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@v8.0.1
|
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist
|
|
||||||
|
|
||||||
- uses: pypa/gh-action-pypi-publish@v1.13.0
|
|
||||||
|
|
||||||
release:
|
|
||||||
name: GitHub Release
|
|
||||||
needs: [version-check, build]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6.0.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8.0.1
|
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist
|
|
||||||
|
|
||||||
- uses: softprops/action-gh-release@v2.6.1
|
|
||||||
with:
|
|
||||||
tag_name: v${{ needs.version-check.outputs.version }}
|
|
||||||
name: v${{ needs.version-check.outputs.version }}
|
|
||||||
files: dist/*
|
|
||||||
generate_release_notes: true
|
|
||||||
make_latest: true
|
|
||||||
-38
@@ -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/
|
|
||||||
demo.run.py
|
|
||||||
|
|
||||||
# Build & distribution
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
*.egg-info/
|
|
||||||
|
|||||||
@@ -1,24 +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.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 (50–1,000,000)
|
|
||||||
- `topup_ton(username, amount)` — top up TON Ads balance (1–1,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.0.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.1
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,174 +1,6 @@
|
|||||||
<div align="center">
|
# pyfragment docs branch
|
||||||
<img src="fragment.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>Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance.</b>
|
- Navigation: docs/SUMMARY.md
|
||||||
</p>
|
|
||||||
|
|
||||||
[](https://pypi.org/project/pyfragment/)
|
|
||||||
[](https://pypi.org/project/pyfragment/)
|
|
||||||
[](https://python.org)
|
|
||||||
[](LICENSE)
|
|
||||||
[](https://github.com/bohd4nx/pyfragment/stargazers)
|
|
||||||
[](https://github.com/bohd4nx/pyfragment/actions)
|
|
||||||
|
|
||||||
[Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
|
|
||||||
|
|
||||||
</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).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Features
|
|
||||||
|
|
||||||
- 💰 **TON Advertisement Topups** — Top up Telegram Ads balance (1–1,000,000,000 TON)
|
|
||||||
- 👑 **Telegram Premium** — Purchase Premium for any user (3, 6, or 12 months)
|
|
||||||
- ⭐ **Telegram Stars Purchases** — Purchase Stars for any Telegram user (50–1,000,000 Stars)
|
|
||||||
- 🔐 **Multi-wallet support** — V4R2 and V5R1 wallet contract versions
|
|
||||||
- ⚡ **Async-first** — Built on `httpx` and `asyncio`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install pyfragment
|
|
||||||
```
|
|
||||||
|
|
||||||
Requires **Python 3.12+**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
```python
|
|
||||||
import asyncio
|
|
||||||
from pyfragment import FragmentClient
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
async with FragmentClient(
|
|
||||||
seed="word1 word2 ... word24",
|
|
||||||
api_key="YOUR_TONAPI_KEY",
|
|
||||||
cookies={
|
|
||||||
"stel_ssid": "...",
|
|
||||||
"stel_dt": "...",
|
|
||||||
"stel_token": "...",
|
|
||||||
"stel_ton_token": "...",
|
|
||||||
},
|
|
||||||
) as client:
|
|
||||||
# Purchase 6 months of Telegram Premium
|
|
||||||
result = await client.purchase_premium("@username", months=6)
|
|
||||||
print(result.transaction_id)
|
|
||||||
|
|
||||||
# Purchase 500 Stars
|
|
||||||
result = await client.purchase_stars("@username", amount=500)
|
|
||||||
print(result.transaction_id)
|
|
||||||
|
|
||||||
# Top up 10 TON to Ads balance
|
|
||||||
result = await client.topup_ton("@username", amount=10)
|
|
||||||
print(result.transaction_id)
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [`examples/`](examples/) folder for ready-to-run scripts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Configuration
|
|
||||||
|
|
||||||
### `FragmentClient` parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Default | Description |
|
|
||||||
| ---------------- | ------------- | -------- | -------- | -------------------------------------------------------- |
|
|
||||||
| `seed` | `str` | ✅ | — | 24-word TON wallet mnemonic phrase |
|
|
||||||
| `api_key` | `str` | ✅ | — | Tonapi key from [tonconsole.com](https://tonconsole.com) |
|
|
||||||
| `cookies` | `dict \| str` | ✅ | — | Fragment session cookies (dict or JSON string) |
|
|
||||||
| `wallet_version` | `str` | ❌ | `"V5R1"` | Wallet contract version: `"V4R2"` or `"V5R1"` |
|
|
||||||
|
|
||||||
### Methods
|
|
||||||
|
|
||||||
> Usernames can be passed with or without `@`.
|
|
||||||
|
|
||||||
| Method | Returns | Description | Limits |
|
|
||||||
| -------------------------------------------------- | ---------------- | ---------------------------------- | ------------------------- |
|
|
||||||
| `purchase_premium(username, months, show_sender=True)` | `PremiumResult` | Purchase Telegram Premium | `months`: 3, 6, or 12 |
|
|
||||||
| `purchase_stars(username, amount, show_sender=True)` | `StarsResult` | Purchase Telegram Stars | `amount`: 50–1,000,000 |
|
|
||||||
| `topup_ton(username, amount, show_sender=True)` | `AdsTopupResult` | Top up Telegram Ads balance | `amount`: 1–1,000,000,000 |
|
|
||||||
| `get_wallet()` | `WalletInfo` | Get wallet address, state, balance | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Getting Required Credentials
|
|
||||||
|
|
||||||
### 🍪 Fragment.com Cookies
|
|
||||||
|
|
||||||
**Prerequisites**: Log in to [fragment.com](https://fragment.com), connect your TON wallet.
|
|
||||||
|
|
||||||
1. Install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm)
|
|
||||||
2. Open [fragment.com](https://fragment.com) while logged in
|
|
||||||
3. Click the extension → **Export** → **Header String**
|
|
||||||
4. Extract these four fields:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"stel_ssid": "...",
|
|
||||||
"stel_dt": "...",
|
|
||||||
"stel_token": "...",
|
|
||||||
"stel_ton_token": "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ Cookies expire. Refresh them if you start getting `FragmentPageError` or auth errors.
|
|
||||||
|
|
||||||
### 🔑 Tonapi Key
|
|
||||||
|
|
||||||
1. Go to [tonconsole.com](https://tonconsole.com)
|
|
||||||
2. Register and generate a new API key
|
|
||||||
3. Pass it as `api_key` to `FragmentClient`
|
|
||||||
|
|
||||||
### 🌱 Wallet Seed Phrase
|
|
||||||
|
|
||||||
If you don't have a TON wallet, create one in [Tonkeeper](https://tonkeeper.com).
|
|
||||||
Go to **Settings → Backup** → copy the 24 words.
|
|
||||||
|
|
||||||
> ⚠️ Never share your seed phrase. Store it offline.
|
|
||||||
|
|
||||||
### 🔐 Wallet Version
|
|
||||||
|
|
||||||
| Version | Use when |
|
|
||||||
| ------- | -------------------------------------------------------------- |
|
|
||||||
| `V5R1` | Default — Tonkeeper / MyTonWallet (wallets created after 2024) |
|
|
||||||
| `V4R2` | Older Tonkeeper or hardware wallets |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗂️ Error Handling
|
|
||||||
|
|
||||||
All exceptions inherit from `FragmentError` — see [`pyfragment/types/exceptions.py`](pyfragment/types/exceptions.py) for the full list.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyfragment import FragmentClient, UserNotFoundError, ConfigurationError, WalletError
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await client.purchase_stars("@unknown", amount=100)
|
|
||||||
except UserNotFoundError:
|
|
||||||
print("User not found on Fragment")
|
|
||||||
except WalletError as e:
|
|
||||||
print(f"Wallet issue: {e}")
|
|
||||||
except ConfigurationError as e:
|
|
||||||
print(f"Bad params: {e}")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
|
|
||||||
### Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx)
|
|
||||||
|
|
||||||
**Star ⭐ this repo if you found it useful!**
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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.**
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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`.**
|
||||||
@@ -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.**
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""
|
|
||||||
Example: initializing FragmentClient.
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
SEED = "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 word16 word17 word18 word19 word20 word21 word22 word23 word24"
|
|
||||||
API_KEY = "YOUR_TONAPI_KEY"
|
|
||||||
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:
|
|
||||||
client = FragmentClient(
|
|
||||||
seed=SEED,
|
|
||||||
api_key=API_KEY,
|
|
||||||
cookies=COOKIES,
|
|
||||||
wallet_version="V5R1", # or "V4R2"
|
|
||||||
)
|
|
||||||
|
|
||||||
wallet = await client.get_wallet()
|
|
||||||
|
|
||||||
print("FragmentClient initialized")
|
|
||||||
print(" %-16s %s" % ("Wallet version:", client.wallet_version))
|
|
||||||
print(" %-16s %s" % ("Address:", wallet.address))
|
|
||||||
print(" %-16s %s" % ("State:", wallet.state))
|
|
||||||
print(" %-16s %s TON" % ("Balance:", wallet.balance))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,45 +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
|
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
|
||||||
API_KEY = "YOUR_TONAPI_KEY"
|
|
||||||
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:
|
|
||||||
client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True)
|
|
||||||
except UserNotFoundError:
|
|
||||||
print(f"User {USERNAME!r} not found on Fragment.")
|
|
||||||
return
|
|
||||||
except ConfigurationError as e:
|
|
||||||
print(f"Invalid parameters: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
print("Premium purchased")
|
|
||||||
print(" %-14s %s" % ("Username:", result.username))
|
|
||||||
print(" %-14s %s months" % ("Duration:", result.months))
|
|
||||||
print(" %-14s %s" % ("Transaction:", result.transaction_id))
|
|
||||||
print(" %-14s %s" % ("Timestamp:", result.timestamp))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,45 +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
|
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
|
||||||
API_KEY = "YOUR_TONAPI_KEY"
|
|
||||||
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 # 50–1 000 000
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True)
|
|
||||||
except UserNotFoundError:
|
|
||||||
print(f"User {USERNAME!r} not found on Fragment.")
|
|
||||||
return
|
|
||||||
except ConfigurationError as e:
|
|
||||||
print(f"Invalid parameters: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
print("Stars purchased")
|
|
||||||
print(" %-14s %s" % ("Username:", result.username))
|
|
||||||
print(" %-14s %s" % ("Stars:", result.stars))
|
|
||||||
print(" %-14s %s" % ("Transaction:", result.transaction_id))
|
|
||||||
print(" %-14s %s" % ("Timestamp:", result.timestamp))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""
|
|
||||||
Example: top up a 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 topup amount + ~0.056 TON for gas.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError, WalletError
|
|
||||||
|
|
||||||
SEED = "word1 word2 ... word24"
|
|
||||||
API_KEY = "YOUR_TONAPI_KEY"
|
|
||||||
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 # TON, integer — 1–1 000 000 000
|
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True)
|
|
||||||
except UserNotFoundError:
|
|
||||||
print(f"User {USERNAME!r} not found on Fragment.")
|
|
||||||
return
|
|
||||||
except WalletError as e:
|
|
||||||
print(f"Wallet error: {e}")
|
|
||||||
return
|
|
||||||
except ConfigurationError as e:
|
|
||||||
print(f"Invalid parameters: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
print("TON topped up")
|
|
||||||
print(" %-14s %s" % ("Username:", result.username))
|
|
||||||
print(" %-14s %s TON" % ("Amount:", result.amount))
|
|
||||||
print(" %-14s %s" % ("Transaction:", result.transaction_id))
|
|
||||||
print(" %-14s %s" % ("Timestamp:", result.timestamp))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512" height="512" style="width: 100%; height: 100%; transform: translate3d(0,0,0); content-visibility: visible;" preserveAspectRatio="xMidYMid meet"><defs><clipPath id="__lottie_element_2"><rect width="512" height="512" x="0" y="0"></rect></clipPath><clipPath id="__lottie_element_4"><path d="M0,0 L100,0 L100,100 L0,100z"></path></clipPath></defs><g clip-path="url(#__lottie_element_2)"><g clip-path="url(#__lottie_element_4)" style="display: block;" transform="matrix(5.119999885559082,0,0,5.119999885559082,0,0)" opacity="1"><g style="display: block;" transform="matrix(1.333299994468689,0,0,1.333299994468689,0,0)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(30,40,51)" fill-opacity="1" d=" M47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 27.68000030517578,5.619999885559082 27.68000030517578,5.619999885559082 C15.5,5.619999885559082 5.619999885559082,15.5 5.619999885559082,27.68000030517578 C5.619999885559082,27.68000030517578 5.619999885559082,47.31999969482422 5.619999885559082,47.31999969482422 C5.619999885559082,59.5 15.5,69.37999725341797 27.68000030517578,69.37999725341797 C27.68000030517578,69.37999725341797 47.31999969482422,69.37999725341797 47.31999969482422,69.37999725341797 C59.5,69.37999725341797 69.37999725341797,59.5 69.37999725341797,47.31999969482422 C69.37999725341797,47.31999969482422 69.37999725341797,27.68000030517578 69.37999725341797,27.68000030517578 C69.37999725341797,15.5 59.5,5.619999885559082 47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082z"></path></g><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(255,255,255)" fill-opacity="1" d=" M36.349998474121094,32.79999923706055 C36.349998474121094,32.79999923706055 21.1299991607666,25.940000534057617 21.1299991607666,25.940000534057617 C20.020000457763672,25.450000762939453 20.389999389648438,23.790000915527344 21.600000381469727,23.790000915527344 C21.600000381469727,23.790000915527344 53.41999816894531,23.790000915527344 53.41999816894531,23.790000915527344 C54.630001068115234,23.790000915527344 54.9900016784668,25.440000534057617 53.880001068115234,25.940000534057617 C53.880001068115234,25.940000534057617 38.66999816894531,32.79999923706055 38.66999816894531,32.79999923706055 C37.939998626708984,33.130001068115234 37.09000015258789,33.130001068115234 36.36000061035156,32.79999923706055 C36.36000061035156,32.79999923706055 36.349998474121094,32.79999923706055 36.349998474121094,32.79999923706055z M56.81999969482422,30.06999969482422 C57.43000030517578,29.1200008392334 56.43000030517578,27.979999542236328 55.400001525878906,28.440000534057617 C55.400001525878906,28.440000534057617 40.72999954223633,35.13999938964844 40.72999954223633,35.13999938964844 C39.72999954223633,35.599998474121094 39.09000015258789,36.61000061035156 39.09000015258789,37.70000076293945 C39.09000015258789,37.70000076293945 39.09000015258789,53.810001373291016 39.09000015258789,53.810001373291016 C39.09000015258789,54.93000030517578 40.54999923706055,55.36000061035156 41.15999984741211,54.41999816894531 C41.15999984741211,54.41999816894531 56.810001373291016,30.06999969482422 56.810001373291016,30.06999969482422 C56.810001373291016,30.06999969482422 56.81999969482422,30.06999969482422 56.81999969482422,30.06999969482422z M19.600000381469727,28.440000534057617 C18.579999923706055,27.979999542236328 17.56999969482422,29.1200008392334 18.18000030517578,30.06999969482422 C18.18000030517578,30.06999969482422 33.84000015258789,54.43000030517578 33.84000015258789,54.43000030517578 C34.45000076293945,55.380001068115234 35.90999984741211,54.939998626708984 35.90999984741211,53.81999969482422 C35.90999984741211,53.81999969482422 35.90999984741211,37.70000076293945 35.90999984741211,37.70000076293945 C35.90999984741211,36.599998474121094 35.27000045776367,35.599998474121094 34.27000045776367,35.13999938964844 C34.27000045776367,35.13999938964844 19.59000015258789,28.450000762939453 19.59000015258789,28.450000762939453 C19.59000015258789,28.450000762939453 19.600000381469727,28.440000534057617 19.600000381469727,28.440000534057617z"></path></g></g></g></g></g></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -1,51 +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 (
|
|
||||||
AdsTopupResult,
|
|
||||||
ClientError,
|
|
||||||
ConfigurationError,
|
|
||||||
CookieError,
|
|
||||||
FragmentAPIError,
|
|
||||||
FragmentError,
|
|
||||||
FragmentPageError,
|
|
||||||
OperationError,
|
|
||||||
ParseError,
|
|
||||||
PremiumResult,
|
|
||||||
StarsResult,
|
|
||||||
TransactionError,
|
|
||||||
UnexpectedError,
|
|
||||||
UserNotFoundError,
|
|
||||||
VerificationError,
|
|
||||||
WalletError,
|
|
||||||
WalletInfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
__version__: str = version("pyfragment")
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"__version__",
|
|
||||||
"FragmentClient",
|
|
||||||
"AdsTopupResult",
|
|
||||||
"PremiumResult",
|
|
||||||
"StarsResult",
|
|
||||||
"WalletInfo",
|
|
||||||
"ClientError",
|
|
||||||
"ConfigurationError",
|
|
||||||
"CookieError",
|
|
||||||
"FragmentAPIError",
|
|
||||||
"FragmentError",
|
|
||||||
"FragmentPageError",
|
|
||||||
"OperationError",
|
|
||||||
"ParseError",
|
|
||||||
"TransactionError",
|
|
||||||
"UnexpectedError",
|
|
||||||
"UserNotFoundError",
|
|
||||||
"VerificationError",
|
|
||||||
"WalletError",
|
|
||||||
]
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
from pyfragment.methods.premium import purchase_premium
|
|
||||||
from pyfragment.methods.stars import purchase_stars
|
|
||||||
from pyfragment.methods.ton import topup_ton
|
|
||||||
from pyfragment.types import (
|
|
||||||
REQUIRED_COOKIE_KEYS,
|
|
||||||
SUPPORTED_WALLET_VERSIONS,
|
|
||||||
AdsTopupResult,
|
|
||||||
ConfigurationError,
|
|
||||||
CookieError,
|
|
||||||
PremiumResult,
|
|
||||||
StarsResult,
|
|
||||||
WalletInfo,
|
|
||||||
WalletVersion,
|
|
||||||
)
|
|
||||||
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).
|
|
||||||
|
|
||||||
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,
|
|
||||||
wallet_version: str = "V5R1",
|
|
||||||
) -> 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(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 = cookies
|
|
||||||
self.wallet_version: WalletVersion = version # type: ignore[assignment]
|
|
||||||
|
|
||||||
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:
|
|
||||||
"""Purchase Telegram Premium for 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``, ``months``, ``timestamp``.
|
|
||||||
"""
|
|
||||||
return await purchase_premium(self, username, months, show_sender)
|
|
||||||
|
|
||||||
async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult:
|
|
||||||
"""Purchase Telegram Stars for 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``, ``stars``, ``timestamp``.
|
|
||||||
"""
|
|
||||||
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 Telegram Ads balance with TON.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
username: Ads account 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``, ``amount``, ``timestamp``.
|
|
||||||
"""
|
|
||||||
return await topup_ton(self, username, amount, show_sender)
|
|
||||||
|
|
||||||
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"``, or ``"frozen"``), and ``balance`` in TON.
|
|
||||||
"""
|
|
||||||
return await get_wallet_info(self)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from pyfragment.methods.premium import purchase_premium
|
|
||||||
from pyfragment.methods.stars import purchase_stars
|
|
||||||
from pyfragment.methods.ton import topup_ton
|
|
||||||
|
|
||||||
__all__ = ["purchase_premium", "purchase_stars", "topup_ton"]
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import json
|
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from pyfragment.types import (
|
|
||||||
BASE_HEADERS,
|
|
||||||
DEVICE,
|
|
||||||
PREMIUM_PAGE,
|
|
||||||
ConfigurationError,
|
|
||||||
FragmentAPIError,
|
|
||||||
FragmentError,
|
|
||||||
PremiumResult,
|
|
||||||
UnexpectedError,
|
|
||||||
UserNotFoundError,
|
|
||||||
)
|
|
||||||
from pyfragment.utils import (
|
|
||||||
execute_transaction_request,
|
|
||||||
fragment_post,
|
|
||||||
get_account_info,
|
|
||||||
get_fragment_hash,
|
|
||||||
process_transaction,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pyfragment.client import FragmentClient
|
|
||||||
|
|
||||||
# Page-specific headers
|
|
||||||
HEADERS: dict[str, str] = {
|
|
||||||
**BASE_HEADERS,
|
|
||||||
"referer": PREMIUM_PAGE,
|
|
||||||
"x-aj-referer": PREMIUM_PAGE,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _search_recipient(
|
|
||||||
session: httpx.AsyncClient,
|
|
||||||
fragment_hash: str,
|
|
||||||
username: str,
|
|
||||||
months: int,
|
|
||||||
) -> str:
|
|
||||||
result = await fragment_post(
|
|
||||||
session,
|
|
||||||
fragment_hash,
|
|
||||||
HEADERS,
|
|
||||||
{
|
|
||||||
"query": username,
|
|
||||||
"months": months,
|
|
||||||
"method": "searchPremiumGiftRecipient",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
recipient = result.get("found", {}).get("recipient")
|
|
||||||
if not recipient:
|
|
||||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
|
||||||
return recipient
|
|
||||||
|
|
||||||
|
|
||||||
async def _init_request(
|
|
||||||
session: httpx.AsyncClient,
|
|
||||||
fragment_hash: str,
|
|
||||||
recipient: str,
|
|
||||||
months: int,
|
|
||||||
) -> str:
|
|
||||||
await fragment_post(
|
|
||||||
session,
|
|
||||||
fragment_hash,
|
|
||||||
HEADERS,
|
|
||||||
{
|
|
||||||
"mode": "new",
|
|
||||||
"lv": "false",
|
|
||||||
"dh": str(int(time.time())),
|
|
||||||
"method": "updatePremiumState",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
result = await fragment_post(
|
|
||||||
session,
|
|
||||||
fragment_hash,
|
|
||||||
HEADERS,
|
|
||||||
{
|
|
||||||
"recipient": recipient,
|
|
||||||
"months": months,
|
|
||||||
"method": "initGiftPremiumRequest",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
req_id = result.get("req_id")
|
|
||||||
if not req_id:
|
|
||||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
|
|
||||||
return req_id
|
|
||||||
|
|
||||||
|
|
||||||
async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult:
|
|
||||||
if months not in (3, 6, 12):
|
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
|
||||||
|
|
||||||
try:
|
|
||||||
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE)
|
|
||||||
account = await get_account_info(client)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(cookies=client.cookies) as session:
|
|
||||||
recipient = await _search_recipient(session, fragment_hash, username, months)
|
|
||||||
req_id = await _init_request(session, fragment_hash, recipient, months)
|
|
||||||
|
|
||||||
tx_data = {
|
|
||||||
"account": json.dumps(account),
|
|
||||||
"device": DEVICE,
|
|
||||||
"transaction": 1,
|
|
||||||
"id": req_id,
|
|
||||||
"show_sender": int(show_sender),
|
|
||||||
"method": "getGiftPremiumLink",
|
|
||||||
}
|
|
||||||
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
|
||||||
|
|
||||||
tx_hash = await process_transaction(client, transaction)
|
|
||||||
return PremiumResult(transaction_id=tx_hash, username=username, months=months)
|
|
||||||
|
|
||||||
except FragmentError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import json
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from pyfragment.types import (
|
|
||||||
BASE_HEADERS,
|
|
||||||
DEVICE,
|
|
||||||
STARS_PAGE,
|
|
||||||
ConfigurationError,
|
|
||||||
FragmentAPIError,
|
|
||||||
FragmentError,
|
|
||||||
StarsResult,
|
|
||||||
UnexpectedError,
|
|
||||||
UserNotFoundError,
|
|
||||||
)
|
|
||||||
from pyfragment.utils import (
|
|
||||||
execute_transaction_request,
|
|
||||||
fragment_post,
|
|
||||||
get_account_info,
|
|
||||||
get_fragment_hash,
|
|
||||||
process_transaction,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pyfragment.client import FragmentClient
|
|
||||||
|
|
||||||
# Page-specific headers
|
|
||||||
HEADERS: dict[str, str] = {
|
|
||||||
**BASE_HEADERS,
|
|
||||||
"referer": STARS_PAGE,
|
|
||||||
"x-aj-referer": STARS_PAGE,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _search_recipient(
|
|
||||||
session: httpx.AsyncClient,
|
|
||||||
fragment_hash: str,
|
|
||||||
username: str,
|
|
||||||
) -> str:
|
|
||||||
result = await fragment_post(
|
|
||||||
session,
|
|
||||||
fragment_hash,
|
|
||||||
HEADERS,
|
|
||||||
{
|
|
||||||
"query": username,
|
|
||||||
"quantity": "",
|
|
||||||
"method": "searchStarsRecipient",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
recipient = result.get("found", {}).get("recipient")
|
|
||||||
if not recipient:
|
|
||||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
|
||||||
return recipient
|
|
||||||
|
|
||||||
|
|
||||||
async def _init_request(
|
|
||||||
session: httpx.AsyncClient,
|
|
||||||
fragment_hash: str,
|
|
||||||
recipient: str,
|
|
||||||
amount: int,
|
|
||||||
) -> str:
|
|
||||||
result = await fragment_post(
|
|
||||||
session,
|
|
||||||
fragment_hash,
|
|
||||||
HEADERS,
|
|
||||||
{
|
|
||||||
"recipient": recipient,
|
|
||||||
"quantity": amount,
|
|
||||||
"method": "initBuyStarsRequest",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
req_id = result.get("req_id")
|
|
||||||
if not req_id:
|
|
||||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
|
|
||||||
return req_id
|
|
||||||
|
|
||||||
|
|
||||||
async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult:
|
|
||||||
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
|
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
|
|
||||||
|
|
||||||
try:
|
|
||||||
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE)
|
|
||||||
account = await get_account_info(client)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(cookies=client.cookies) as session:
|
|
||||||
recipient = await _search_recipient(session, fragment_hash, username)
|
|
||||||
req_id = await _init_request(session, fragment_hash, recipient, amount)
|
|
||||||
|
|
||||||
tx_data = {
|
|
||||||
"account": json.dumps(account),
|
|
||||||
"device": DEVICE,
|
|
||||||
"transaction": 1,
|
|
||||||
"id": req_id,
|
|
||||||
"show_sender": int(show_sender),
|
|
||||||
"method": "getBuyStarsLink",
|
|
||||||
}
|
|
||||||
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
|
||||||
|
|
||||||
tx_hash = await process_transaction(client, transaction)
|
|
||||||
return StarsResult(transaction_id=tx_hash, username=username, stars=amount)
|
|
||||||
|
|
||||||
except FragmentError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import json
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from pyfragment.types import (
|
|
||||||
BASE_HEADERS,
|
|
||||||
DEVICE,
|
|
||||||
TON_PAGE,
|
|
||||||
AdsTopupResult,
|
|
||||||
ConfigurationError,
|
|
||||||
FragmentAPIError,
|
|
||||||
FragmentError,
|
|
||||||
UnexpectedError,
|
|
||||||
UserNotFoundError,
|
|
||||||
)
|
|
||||||
from pyfragment.utils import (
|
|
||||||
execute_transaction_request,
|
|
||||||
fragment_post,
|
|
||||||
get_account_info,
|
|
||||||
get_fragment_hash,
|
|
||||||
process_transaction,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pyfragment.client import FragmentClient
|
|
||||||
|
|
||||||
# Page-specific headers
|
|
||||||
HEADERS: dict[str, str] = {
|
|
||||||
**BASE_HEADERS,
|
|
||||||
"referer": TON_PAGE,
|
|
||||||
"x-aj-referer": TON_PAGE,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _search_recipient(
|
|
||||||
session: httpx.AsyncClient,
|
|
||||||
fragment_hash: str,
|
|
||||||
username: str,
|
|
||||||
) -> str:
|
|
||||||
await fragment_post(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"})
|
|
||||||
result = await fragment_post(
|
|
||||||
session,
|
|
||||||
fragment_hash,
|
|
||||||
HEADERS,
|
|
||||||
{
|
|
||||||
"query": username,
|
|
||||||
"method": "searchAdsTopupRecipient",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
recipient = result.get("found", {}).get("recipient")
|
|
||||||
if not recipient:
|
|
||||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
|
||||||
return recipient
|
|
||||||
|
|
||||||
|
|
||||||
async def _init_request(
|
|
||||||
session: httpx.AsyncClient,
|
|
||||||
fragment_hash: str,
|
|
||||||
recipient: str,
|
|
||||||
amount: int,
|
|
||||||
) -> str:
|
|
||||||
result = await fragment_post(
|
|
||||||
session,
|
|
||||||
fragment_hash,
|
|
||||||
HEADERS,
|
|
||||||
{
|
|
||||||
"recipient": recipient,
|
|
||||||
"amount": amount,
|
|
||||||
"method": "initAdsTopupRequest",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
req_id = result.get("req_id")
|
|
||||||
if not req_id:
|
|
||||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
|
|
||||||
return req_id
|
|
||||||
|
|
||||||
|
|
||||||
async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
|
||||||
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
|
||||||
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
|
||||||
|
|
||||||
try:
|
|
||||||
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE)
|
|
||||||
account = await get_account_info(client)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(cookies=client.cookies) as session:
|
|
||||||
recipient = await _search_recipient(session, fragment_hash, username)
|
|
||||||
req_id = await _init_request(session, fragment_hash, recipient, amount)
|
|
||||||
|
|
||||||
tx_data = {
|
|
||||||
"account": json.dumps(account),
|
|
||||||
"device": DEVICE,
|
|
||||||
"transaction": 1,
|
|
||||||
"id": req_id,
|
|
||||||
"show_sender": int(show_sender),
|
|
||||||
"method": "getAdsTopupLink",
|
|
||||||
}
|
|
||||||
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
from pyfragment.types.constants import (
|
|
||||||
BASE_HEADERS,
|
|
||||||
DEVICE,
|
|
||||||
MIN_TON_BALANCE,
|
|
||||||
PREMIUM_PAGE,
|
|
||||||
REQUIRED_COOKIE_KEYS,
|
|
||||||
STARS_PAGE,
|
|
||||||
SUPPORTED_WALLET_VERSIONS,
|
|
||||||
TON_PAGE,
|
|
||||||
WALLET_CLASSES,
|
|
||||||
WalletVersion,
|
|
||||||
)
|
|
||||||
from pyfragment.types.exceptions import (
|
|
||||||
ClientError,
|
|
||||||
ConfigurationError,
|
|
||||||
CookieError,
|
|
||||||
FragmentAPIError,
|
|
||||||
FragmentError,
|
|
||||||
FragmentPageError,
|
|
||||||
OperationError,
|
|
||||||
ParseError,
|
|
||||||
TransactionError,
|
|
||||||
UnexpectedError,
|
|
||||||
UserNotFoundError,
|
|
||||||
VerificationError,
|
|
||||||
WalletError,
|
|
||||||
)
|
|
||||||
from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# constants
|
|
||||||
"BASE_HEADERS",
|
|
||||||
"DEVICE",
|
|
||||||
"MIN_TON_BALANCE",
|
|
||||||
"PREMIUM_PAGE",
|
|
||||||
"REQUIRED_COOKIE_KEYS",
|
|
||||||
"STARS_PAGE",
|
|
||||||
"SUPPORTED_WALLET_VERSIONS",
|
|
||||||
"TON_PAGE",
|
|
||||||
"WALLET_CLASSES",
|
|
||||||
"WalletVersion",
|
|
||||||
# client exceptions
|
|
||||||
"ClientError",
|
|
||||||
"ConfigurationError",
|
|
||||||
"CookieError",
|
|
||||||
# fragment exceptions
|
|
||||||
"FragmentAPIError",
|
|
||||||
"FragmentError",
|
|
||||||
"FragmentPageError",
|
|
||||||
"OperationError",
|
|
||||||
"ParseError",
|
|
||||||
"TransactionError",
|
|
||||||
"UnexpectedError",
|
|
||||||
"UserNotFoundError",
|
|
||||||
"VerificationError",
|
|
||||||
"WalletError",
|
|
||||||
# result types
|
|
||||||
"AdsTopupResult",
|
|
||||||
"PremiumResult",
|
|
||||||
"StarsResult",
|
|
||||||
"WalletInfo",
|
|
||||||
]
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import json
|
|
||||||
from typing import 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, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
|
||||||
|
|
||||||
# Minimum wallet balance required to cover TON network gas fees.
|
|
||||||
MIN_TON_BALANCE: float = 0.056
|
|
||||||
|
|
||||||
# Required Fragment session cookie keys
|
|
||||||
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
|
||||||
|
|
||||||
# Fragment page URLs
|
|
||||||
STARS_PAGE: str = "https://fragment.com/stars/buy"
|
|
||||||
PREMIUM_PAGE: str = "https://fragment.com/premium/gift"
|
|
||||||
TON_PAGE: str = "https://fragment.com/ads/topup"
|
|
||||||
|
|
||||||
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
|
|
||||||
DEVICE: str = json.dumps(
|
|
||||||
{
|
|
||||||
"platform": "iphone",
|
|
||||||
"appName": "Tonkeeper",
|
|
||||||
"appVersion": "5.5.2",
|
|
||||||
"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": "https://fragment.com",
|
|
||||||
"priority": "u=1, i",
|
|
||||||
"sec-fetch-dest": "empty",
|
|
||||||
"sec-fetch-mode": "cors",
|
|
||||||
"sec-fetch-site": "same-origin",
|
|
||||||
"user-agent": (
|
|
||||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) "
|
|
||||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
|
|
||||||
),
|
|
||||||
"x-requested-with": "XMLHttpRequest",
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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: got {count} words, expected 12, 18, or 24."
|
|
||||||
INVALID_API_KEY = "Invalid Tonapi key: got {length} characters, expected at least 68. Get one at https://tonconsole.com."
|
|
||||||
INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months."
|
|
||||||
INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars."
|
|
||||||
INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON."
|
|
||||||
|
|
||||||
|
|
||||||
class CookieError(ClientError):
|
|
||||||
"""Raised when cookies are unreadable or missing required fields."""
|
|
||||||
|
|
||||||
READ_FAILED = "Failed to parse cookies: {exc}"
|
|
||||||
MISSING_KEYS = (
|
|
||||||
"Cookies are missing or have empty values for: {keys}. " "Open Fragment.com in your browser and copy 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}'. " "The session may have expired — 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} for {url}. " "Check that your cookies are valid and not expired."
|
|
||||||
NOT_FOUND = (
|
|
||||||
"Fragment hash not found in the page source of {url}. " "The page structure may have changed or you are not logged in."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UserNotFoundError(FragmentAPIError):
|
|
||||||
"""Raised when the target Telegram user is not found on Fragment."""
|
|
||||||
|
|
||||||
NOT_FOUND = (
|
|
||||||
"Telegram user '{username}' was not found on Fragment. " "Make sure the username is correct and the account exists."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TransactionError(FragmentAPIError):
|
|
||||||
"""Raised when a TON transaction fails to build or broadcast."""
|
|
||||||
|
|
||||||
INVALID_PAYLOAD = (
|
|
||||||
"Fragment returned an invalid transaction payload. " "The API response is missing expected 'transaction.messages' data."
|
|
||||||
)
|
|
||||||
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
|
||||||
|
|
||||||
|
|
||||||
class ParseError(FragmentAPIError):
|
|
||||||
"""Raised when a Fragment API response or payload cannot be parsed."""
|
|
||||||
|
|
||||||
UNPARSEABLE = "Fragment API returned an unparseable response for '{context}': {exc}"
|
|
||||||
|
|
||||||
|
|
||||||
class VerificationError(FragmentAPIError):
|
|
||||||
"""Raised when Fragment requires KYC verification before proceeding."""
|
|
||||||
|
|
||||||
KYC_REQUIRED = "Fragment requires identity (KYC) verification. " "Complete it 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 = "TON wallet balance is too low: {balance:.4f} TON available, {required:.4f} TON required."
|
|
||||||
BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}"
|
|
||||||
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}"
|
|
||||||
WALLET_INFO_FAILED = "Failed to retrieve wallet info: {exc}"
|
|
||||||
|
|
||||||
|
|
||||||
class UnexpectedError(OperationError):
|
|
||||||
"""Raised when an unexpected error occurs during an API call."""
|
|
||||||
|
|
||||||
UNEXPECTED = "An unexpected error occurred: {exc}"
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"FragmentError",
|
|
||||||
"ClientError",
|
|
||||||
"ConfigurationError",
|
|
||||||
"CookieError",
|
|
||||||
"FragmentAPIError",
|
|
||||||
"FragmentPageError",
|
|
||||||
"UserNotFoundError",
|
|
||||||
"TransactionError",
|
|
||||||
"ParseError",
|
|
||||||
"VerificationError",
|
|
||||||
"OperationError",
|
|
||||||
"WalletError",
|
|
||||||
"UnexpectedError",
|
|
||||||
]
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult", "WalletInfo"]
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
months: int
|
|
||||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"PremiumResult(username='{self.username}', months={self.months}, tx='{self.transaction_id}')"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StarsResult:
|
|
||||||
"""Result of a successful Telegram Stars purchase."""
|
|
||||||
|
|
||||||
transaction_id: str
|
|
||||||
username: str
|
|
||||||
stars: int
|
|
||||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"StarsResult(username='{self.username}', stars={self.stars}, tx='{self.transaction_id}')"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AdsTopupResult:
|
|
||||||
"""Result of a successful Telegram Ads balance top-up."""
|
|
||||||
|
|
||||||
transaction_id: str
|
|
||||||
username: str
|
|
||||||
amount: int
|
|
||||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
from pyfragment.utils.decoder import clean_decode
|
|
||||||
from pyfragment.utils.http import (
|
|
||||||
execute_transaction_request,
|
|
||||||
fragment_post,
|
|
||||||
get_fragment_hash,
|
|
||||||
parse_json_response,
|
|
||||||
)
|
|
||||||
from pyfragment.utils.wallet import get_account_info, process_transaction
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"clean_decode",
|
|
||||||
"execute_transaction_request",
|
|
||||||
"fragment_post",
|
|
||||||
"get_account_info",
|
|
||||||
"get_fragment_hash",
|
|
||||||
"parse_json_response",
|
|
||||||
"process_transaction",
|
|
||||||
]
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import base64
|
|
||||||
|
|
||||||
from pytoniq_core import Cell
|
|
||||||
|
|
||||||
from pyfragment.types import ParseError
|
|
||||||
|
|
||||||
|
|
||||||
def clean_decode(payload: str) -> str:
|
|
||||||
"""Decode a base64-encoded BOC payload to a plain-text comment string.
|
|
||||||
|
|
||||||
Fragment transaction payloads are BOC-serialised TVM cells. This function
|
|
||||||
base64-decodes the payload, parses the cell, skips the 32-bit op-code
|
|
||||||
prefix, and reads the snake-encoded UTF-8 comment.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
payload: Base64url-encoded BOC string (padding is added automatically).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Decoded comment string, or ``""`` for an empty payload.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ParseError: If the payload cannot be decoded or parsed.
|
|
||||||
"""
|
|
||||||
s = payload.strip()
|
|
||||||
if not s:
|
|
||||||
return ""
|
|
||||||
s += "=" * (-len(s) % 4)
|
|
||||||
try:
|
|
||||||
boc = base64.b64decode(s)
|
|
||||||
cell = Cell.one_from_boc(boc)
|
|
||||||
sl = cell.begin_parse()
|
|
||||||
sl.load_uint(32) # op code — always 0 for text comment
|
|
||||||
return sl.load_snake_string().strip()
|
|
||||||
except Exception as exc:
|
|
||||||
raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from pyfragment.types import FragmentPageError, ParseError, VerificationError
|
|
||||||
|
|
||||||
|
|
||||||
async def get_fragment_hash(
|
|
||||||
cookies: dict[str, Any],
|
|
||||||
headers: dict[str, str],
|
|
||||||
page_url: str,
|
|
||||||
) -> 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.
|
|
||||||
|
|
||||||
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": "https://fragment.com/",
|
|
||||||
"sec-fetch-dest": "document",
|
|
||||||
"sec-fetch-mode": "navigate",
|
|
||||||
"upgrade-insecure-requests": "1",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(cookies=cookies) as session:
|
|
||||||
response = await session.get(page_url, headers=page_headers)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
|
|
||||||
|
|
||||||
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
|
|
||||||
if not match:
|
|
||||||
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
|
|
||||||
|
|
||||||
return match.group(1)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
|
|
||||||
"""Parse a Fragment API JSON response.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
response: The HTTP response object.
|
|
||||||
context: Human-readable name of the API method, used in error messages.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed response as a dict.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ParseError: If the response body cannot be decoded as JSON.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return response.json()
|
|
||||||
except Exception as exc:
|
|
||||||
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
async def fragment_post(
|
|
||||||
session: httpx.AsyncClient,
|
|
||||||
fragment_hash: str,
|
|
||||||
headers: dict[str, str],
|
|
||||||
data: dict[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""POST a single request to the Fragment API.
|
|
||||||
|
|
||||||
Builds the ``/api?hash=`` URL, sends the request, and returns the
|
|
||||||
parsed JSON body. Use this for every API method call — search,
|
|
||||||
init, state updates, etc.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: Active httpx session with Fragment cookies.
|
|
||||||
fragment_hash: Short-lived hash from the Fragment page HTML.
|
|
||||||
headers: Page-specific HTTP headers.
|
|
||||||
data: Form data payload; must include a ``"method"`` key.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Parsed API response as a dict.
|
|
||||||
"""
|
|
||||||
resp = await session.post(
|
|
||||||
f"https://fragment.com/api?hash={fragment_hash}",
|
|
||||||
headers=headers,
|
|
||||||
data=data,
|
|
||||||
)
|
|
||||||
return parse_json_response(resp, data.get("method", "request"))
|
|
||||||
|
|
||||||
|
|
||||||
async def execute_transaction_request(
|
|
||||||
session: httpx.AsyncClient,
|
|
||||||
headers: dict,
|
|
||||||
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_post(session, fragment_hash, headers, tx_data)
|
|
||||||
|
|
||||||
if transaction.get("need_verify"):
|
|
||||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
|
||||||
|
|
||||||
return transaction
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from tonutils.clients import TonapiClient
|
|
||||||
from tonutils.exceptions import ProviderResponseError
|
|
||||||
from tonutils.types import NetworkGlobalID
|
|
||||||
|
|
||||||
from pyfragment.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError
|
|
||||||
from pyfragment.types.results import WalletInfo
|
|
||||||
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:
|
|
||||||
"""Sign and broadcast a Fragment transaction to the TON network.
|
|
||||||
|
|
||||||
Validates the payload structure, checks the wallet balance, decodes the
|
|
||||||
on-chain comment, and calls ``wallet.transfer``.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
|
||||||
transaction_data: Raw transaction dict from ``execute_transaction_request``.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Normalised transaction hash string.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TransactionError: If the payload is malformed or the broadcast fails.
|
|
||||||
WalletError: If the wallet balance is too low or cannot be fetched.
|
|
||||||
"""
|
|
||||||
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
|
|
||||||
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
|
||||||
|
|
||||||
message = transaction_data["transaction"]["messages"][0]
|
|
||||||
amount_ton = int(message["amount"]) / 1_000_000_000
|
|
||||||
|
|
||||||
# TODO: Investigate 406 'inbound external message rejected before smart-contract execution'.
|
|
||||||
# This happens when the previous transaction's seqno hasn't been confirmed on-chain yet,
|
|
||||||
# causing the wallet contract to reject the new message.
|
|
||||||
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))
|
|
||||||
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(2):
|
|
||||||
try:
|
|
||||||
result = await wallet.transfer(
|
|
||||||
destination=message["address"],
|
|
||||||
amount=int(message["amount"]), # nanotons, not TON
|
|
||||||
body=payload,
|
|
||||||
)
|
|
||||||
return result.normalized_hash
|
|
||||||
except ProviderResponseError as exc:
|
|
||||||
if exc.code == 429 and attempt == 0:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
except (WalletError, TransactionError):
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "pyfragment"
|
|
||||||
version = "2026.0.1"
|
|
||||||
description = "Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance."
|
|
||||||
readme = "README.md"
|
|
||||||
license = { text = "MIT" }
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }]
|
|
||||||
keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"]
|
|
||||||
classifiers = [
|
|
||||||
"Development Status :: 5 - Production/Stable",
|
|
||||||
"Intended Audience :: Developers",
|
|
||||||
"License :: OSI Approved :: MIT License",
|
|
||||||
"Natural Language :: English",
|
|
||||||
"Operating System :: OS Independent",
|
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
|
||||||
"Programming Language :: Python :: 3.12",
|
|
||||||
"Framework :: AsyncIO",
|
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
||||||
"Topic :: Internet",
|
|
||||||
"Topic :: Office/Business :: Financial",
|
|
||||||
"Typing :: Typed",
|
|
||||||
]
|
|
||||||
dependencies = [
|
|
||||||
"httpx==0.28.1",
|
|
||||||
"tonutils[pytoniq]==2.0.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest==9.0.2",
|
|
||||||
"pytest-asyncio==1.3.0",
|
|
||||||
"ruff",
|
|
||||||
"black",
|
|
||||||
]
|
|
||||||
|
|
||||||
[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.black]
|
|
||||||
line-length = 128
|
|
||||||
target-version = ["py312"]
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
line-length = 128
|
|
||||||
target-version = "py312"
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort
|
|
||||||
select = ["E", "F", "W", "I"]
|
|
||||||
ignore = ["E501"]
|
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
|
||||||
"tests/*" = ["E402"]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
-e .[dev]
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
-e .
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to UTF-8."""
|
|
||||||
|
|
||||||
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",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@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}"
|
|
||||||
|
|
||||||
|
|
||||||
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!!!")
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
"""Unit tests for FragmentClient — init validation and cookie parsing (no network calls)."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
|
||||||
from pyfragment.types import ConfigurationError, CookieError
|
|
||||||
|
|
||||||
VALID_SEED = "abandon " * 23 + "about"
|
|
||||||
VALID_API_KEY = "A" * 68
|
|
||||||
VALID_COOKIES = {
|
|
||||||
"stel_ssid": "x",
|
|
||||||
"stel_dt": "x",
|
|
||||||
"stel_token": "x",
|
|
||||||
"stel_ton_token": "x",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
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_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_missing_api_key_raises() -> None:
|
|
||||||
with pytest.raises(ConfigurationError):
|
|
||||||
FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES)
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
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_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
|
|
||||||
|
|
||||||
|
|
||||||
def test_short_api_key_raises() -> None:
|
|
||||||
with pytest.raises(ConfigurationError):
|
|
||||||
FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"""Tests for get_fragment_hash() — fetches a valid lowercase hex hash
|
|
||||||
from the fragment.com/stars/buy page source."""
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from pyfragment.types import BASE_HEADERS, STARS_PAGE
|
|
||||||
from pyfragment.utils import get_fragment_hash
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_hash_is_valid_hex(cookies: dict) -> None:
|
|
||||||
result = await get_fragment_hash(cookies, BASE_HEADERS, STARS_PAGE)
|
|
||||||
assert isinstance(result, str)
|
|
||||||
assert len(result) >= 10, f"hash too short: {result!r}"
|
|
||||||
assert re.fullmatch(r"[a-f0-9]+", result), f"not a hex string: {result!r}"
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
"""Unit tests for process_transaction() — balance checks before broadcast."""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from pyfragment.types import WalletError
|
|
||||||
from pyfragment.utils.wallet import process_transaction
|
|
||||||
|
|
||||||
VALID_SEED = "abandon " * 23 + "about"
|
|
||||||
|
|
||||||
# Minimal transaction payload: 0.5 TON = 500_000_000 nanotons
|
|
||||||
TRANSACTION_DATA = {
|
|
||||||
"transaction": {
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
|
|
||||||
"amount": "500000000",
|
|
||||||
"payload": "",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _make_client(api_key: str = "test_key") -> MagicMock:
|
|
||||||
client = MagicMock()
|
|
||||||
client.api_key = api_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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sufficient_balance_broadcasts() -> None:
|
|
||||||
# 0.5 TON amount + 0.056 TON gas = 0.556 TON required; wallet has 1 TON
|
|
||||||
client = _make_client()
|
|
||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
|
||||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
|
||||||
patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"),
|
|
||||||
):
|
|
||||||
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)
|
|
||||||
|
|
||||||
result = await process_transaction(client, TRANSACTION_DATA)
|
|
||||||
|
|
||||||
assert result == "abc123"
|
|
||||||
wallet.transfer.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_insufficient_balance_raises_wallet_error() -> None:
|
|
||||||
# wallet has 0.1 TON, needs 0.556 TON
|
|
||||||
client = _make_client()
|
|
||||||
wallet = _make_wallet(balance_nanotons=100_000_000)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
with pytest.raises(WalletError, match="required"):
|
|
||||||
await process_transaction(client, TRANSACTION_DATA)
|
|
||||||
|
|
||||||
wallet.transfer.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_exactly_minimum_balance_broadcasts() -> None:
|
|
||||||
# exactly amount + gas: 500_000_000 + 56_000_000 = 556_000_000 nanotons
|
|
||||||
client = _make_client()
|
|
||||||
wallet = _make_wallet(balance_nanotons=556_000_000)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
|
|
||||||
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes,
|
|
||||||
patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"),
|
|
||||||
):
|
|
||||||
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)
|
|
||||||
|
|
||||||
result = await process_transaction(client, TRANSACTION_DATA)
|
|
||||||
|
|
||||||
assert result == "abc123"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_one_nanoton_below_minimum_raises() -> None:
|
|
||||||
# 556_000_000 - 1 nanoton: just below threshold
|
|
||||||
client = _make_client()
|
|
||||||
wallet = _make_wallet(balance_nanotons=555_999_999)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
with pytest.raises(WalletError, match="required"):
|
|
||||||
await process_transaction(client, TRANSACTION_DATA)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_invalid_payload_raises_transaction_error() -> None:
|
|
||||||
from pyfragment.types import TransactionError
|
|
||||||
|
|
||||||
client = _make_client()
|
|
||||||
with pytest.raises(TransactionError):
|
|
||||||
await process_transaction(client, {"transaction": {}})
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Unit tests for method-level input validation — no network calls."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from pyfragment import FragmentClient
|
|
||||||
from pyfragment.types import ConfigurationError
|
|
||||||
|
|
||||||
VALID_SEED = "abandon " * 23 + "about"
|
|
||||||
VALID_API_KEY = "A" * 68
|
|
||||||
VALID_COOKIES = {
|
|
||||||
"stel_ssid": "x",
|
|
||||||
"stel_dt": "x",
|
|
||||||
"stel_token": "x",
|
|
||||||
"stel_ton_token": "x",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client() -> FragmentClient:
|
|
||||||
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_purchase_premium_invalid_months_raises(client: FragmentClient) -> None:
|
|
||||||
with pytest.raises(ConfigurationError):
|
|
||||||
await client.purchase_premium("@user", months=5)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_purchase_premium_valid_months(client: FragmentClient) -> None:
|
|
||||||
"""Validation passes for 3/6/12 — network error expected, not ConfigurationError."""
|
|
||||||
for months in (3, 6, 12):
|
|
||||||
with pytest.raises(Exception) as exc_info:
|
|
||||||
await client.purchase_premium("@user", months=months)
|
|
||||||
assert not isinstance(exc_info.value, ConfigurationError)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_purchase_stars_amount_too_low_raises(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_raises(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_raises(client: FragmentClient) -> None:
|
|
||||||
with pytest.raises(ConfigurationError):
|
|
||||||
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_topup_ton_amount_zero_raises(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_raises(client: FragmentClient) -> None:
|
|
||||||
with pytest.raises(ConfigurationError):
|
|
||||||
await client.topup_ton("@user", amount=1_000_000_001)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def cookies():
|
|
||||||
"""Load Fragment cookies from cookies.json; skip the test if unavailable."""
|
|
||||||
cookies_path = Path(__file__).resolve().parents[1] / "cookies.json"
|
|
||||||
if not cookies_path.exists():
|
|
||||||
pytest.skip("cookies.json not found")
|
|
||||||
try:
|
|
||||||
with cookies_path.open("r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
except Exception as exc:
|
|
||||||
pytest.skip(f"Cookies unavailable — {exc}")
|
|
||||||
Reference in New Issue
Block a user