mirror of
https://github.com/vibe-existing/pyfragment.git
synced 2026-07-25 06:54:31 +00:00
@@ -1,11 +0,0 @@
|
||||
# Fragment.com cookies - copy from browser after login (Header String format)
|
||||
# Hash is now fetched dynamically
|
||||
|
||||
# TON wallet seed phrase - 12 or 24 words separated by spaces
|
||||
SEED = "your_ton_wallet_seed_phrase_here"
|
||||
|
||||
# TON API key - get from https://tonconsole.com
|
||||
API_KEY = "your_ton_api_key_here"
|
||||
|
||||
# TON wallet contract version: V4R2 or V5R1 (default: V5R1)
|
||||
WALLET_VERSION = "V5R1"
|
||||
@@ -0,0 +1,101 @@
|
||||
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.
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Ask a question or start a discussion
|
||||
url: https://github.com/bohd4nx/pyfragment/discussions
|
||||
about: General questions, ideas, and community help go here — not in the issue tracker.
|
||||
@@ -0,0 +1,51 @@
|
||||
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.
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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
|
||||
@@ -8,3 +8,12 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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,101 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements.txt pytest pytest-asyncio
|
||||
|
||||
- name: Write cookies.json
|
||||
if: ${{ env.COOKIES_JSON != '' }}
|
||||
run: echo "$COOKIES_JSON" > cookies.json
|
||||
env:
|
||||
COOKIES_JSON: ${{ secrets.COOKIES_JSON }}
|
||||
|
||||
- name: Run tests
|
||||
run: pytest
|
||||
+14
-1
@@ -23,4 +23,17 @@ logs/
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
cookies.json
|
||||
|
||||
# Testing & tooling artifacts
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
demo.run.py
|
||||
|
||||
# Build & distribution
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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
|
||||
@@ -0,0 +1,21 @@
|
||||
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,71 +1,117 @@
|
||||
<div align="center">
|
||||
<img src="fragment.svg" alt="Fragment Logo" width="120" height="120" style="border-radius: 24px;">
|
||||
|
||||
<h1 style="margin-top: 24px;">💎 Fragment API by @bohd4nx</h1>
|
||||
<h1 style="margin-top: 24px;">Fragment API</h1>
|
||||
|
||||
<p style="font-size: 18px; margin-bottom: 24px;">
|
||||
<b>Automate TON topups, Telegram Premium purchases, and Stars transactions via Fragment.com</b>
|
||||
<b>Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance.</b>
|
||||
</p>
|
||||
|
||||
[](https://python.org)
|
||||
[](https://github.com/nessshon/tonutils)
|
||||
[](https://github.com/bohd4nx/FragmentAPI/stargazers)
|
||||
[](https://github.com/bohd4nx/FragmentAPI/issues)
|
||||
[](https://github.com/bohd4nx/FragmentAPI/actions)
|
||||
[](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/fragmentapi/issues) · [Request Feature](https://github.com/bohd4nx/fragmentapi/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
|
||||
[Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
|
||||
|
||||
</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** — Send TON directly to Fragment ad accounts (1–1,000,000,000 TON)
|
||||
- 👑 **Telegram Premium Gifts** — Purchase Premium subscriptions for any user (3, 6, or 12 months)
|
||||
- ⭐ **Telegram Stars Purchases** — Buy Stars and send them to any Telegram user (50–1,000,000 Stars)
|
||||
- 🔐 **Multi-wallet support** — Configurable wallet contract version (V4R2 / V5R1)
|
||||
- 💰 **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
|
||||
|
||||
### 1. Installation
|
||||
```python
|
||||
import asyncio
|
||||
from pyfragment import FragmentClient
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bohd4nx/FragmentAPI.git
|
||||
cd FragmentAPI
|
||||
pip install -r requirements.txt
|
||||
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())
|
||||
```
|
||||
|
||||
### 2. Configuration
|
||||
See the [`examples/`](examples/) folder for ready-to-run scripts.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cp cookies.example.json cookies.json
|
||||
```
|
||||
---
|
||||
|
||||
Edit `.env`:
|
||||
## 🔧 Configuration
|
||||
|
||||
```env
|
||||
# 24-word TON wallet seed phrase
|
||||
SEED = word1 word2 word3 ... word24
|
||||
### `FragmentClient` parameters
|
||||
|
||||
# API key from @tonapibot on Telegram
|
||||
API_KEY = your_tonapi_key_here
|
||||
| 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"` |
|
||||
|
||||
# Wallet contract version: V4R2 or V5R1 (default: V5R1)
|
||||
WALLET_VERSION = V5R1
|
||||
```
|
||||
### Methods
|
||||
|
||||
### 3. Getting Required Data
|
||||
> Usernames can be passed with or without `@`.
|
||||
|
||||
#### 🍪 Fragment.com Cookies
|
||||
| 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 | — |
|
||||
|
||||
**Prerequisites**: Log in to Telegram on Fragment and connect the TON wallet you'll use for payments.
|
||||
---
|
||||
|
||||
1. Install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) extension
|
||||
2. Open [fragment.com](https://fragment.com) and make sure you're logged in
|
||||
3. Click the Cookie Editor icon → **Export** → **Header String**
|
||||
4. Split the result into the four fields in `cookies.json`:
|
||||
## ⚙️ 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
|
||||
{
|
||||
@@ -76,107 +122,48 @@ WALLET_VERSION = V5R1
|
||||
}
|
||||
```
|
||||
|
||||
#### 🔐 TON Wallet Seed Phrase
|
||||
> ⚠️ Cookies expire. Refresh them if you start getting `FragmentPageError` or auth errors.
|
||||
|
||||
If you don't have a TON wallet, create one in [Tonkeeper](https://tonkeeper.com) (iOS / Android).
|
||||
Go to **Settings → Backup**, copy the 24 words and paste them into `SEED` in `.env`.
|
||||
|
||||
> ⚠️ Never share your seed phrase with anyone. Store it offline.
|
||||
|
||||
#### 🔑 TON API Key
|
||||
### 🔑 Tonapi Key
|
||||
|
||||
1. Go to [tonconsole.com](https://tonconsole.com)
|
||||
2. Create an account and log in
|
||||
3. Generate a new API key
|
||||
4. Paste it into `API_KEY` in `.env`
|
||||
2. Register and generate a new API key
|
||||
3. Pass it as `api_key` to `FragmentClient`
|
||||
|
||||
#### 🔐 Wallet Version
|
||||
### 🌱 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 wallets |
|
||||
| `V4R2` | Older Tonkeeper or hardware wallets |
|
||||
|
||||
Not sure? Run this to check which address matches your wallet:
|
||||
---
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import asyncio
|
||||
from tonutils.clients import TonapiClient
|
||||
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
||||
from tonutils.types import NetworkGlobalID
|
||||
from app.core import config
|
||||
## 🗂️ Error Handling
|
||||
|
||||
client = TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY)
|
||||
w4, _, _, _ = WalletV4R2.from_mnemonic(client=client, mnemonic=config.SEED)
|
||||
w5, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
|
||||
print('V4R2:', w4.address.to_str(True, True))
|
||||
print('V5R1:', w5.address.to_str(True, True))
|
||||
"
|
||||
```
|
||||
|
||||
### 4. Usage
|
||||
|
||||
#### Run Examples
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
#### Programmatic Usage
|
||||
All exceptions inherit from `FragmentError` — see [`pyfragment/types/exceptions.py`](pyfragment/types/exceptions.py) for the full list.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from app.methods import topup_ton, buy_premium, buy_stars
|
||||
from pyfragment import FragmentClient, UserNotFoundError, ConfigurationError, WalletError
|
||||
|
||||
async def main():
|
||||
# Send 10 TON to @username
|
||||
result = await topup_ton("@username", 10)
|
||||
print(result)
|
||||
|
||||
# Gift 6 months of Telegram Premium (anonymous — recipient won't see sender)
|
||||
result = await buy_premium("@username", 6, show_sender=False)
|
||||
print(result)
|
||||
|
||||
# Buy 500 Stars for @username
|
||||
result = await buy_stars("@username", 500)
|
||||
print(result)
|
||||
|
||||
asyncio.run(main())
|
||||
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}")
|
||||
```
|
||||
|
||||
**Return format** (on success):
|
||||
|
||||
```python
|
||||
{
|
||||
"success": True,
|
||||
"data": {
|
||||
"transaction_id": "<TL-B ExternalMessage ...>",
|
||||
"username": "@username",
|
||||
"amount": 10, # or "months" for Premium
|
||||
"timestamp": 1741234567
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Return format** (on failure):
|
||||
|
||||
```python
|
||||
{
|
||||
"success": False,
|
||||
"error": "Telegram user '@unknown' was not found on Fragment."
|
||||
}
|
||||
```
|
||||
|
||||
### Supported Operations
|
||||
|
||||
| Operation | Function | Parameters | Limits |
|
||||
| ------------------ | ----------------------------------------------------- | ----------------------------------- | ------------------- |
|
||||
| **TON Topup** | `topup_ton(username, amount, show_sender=True)` | Username, TON amount, show sender | 1–1,000,000,000 TON |
|
||||
| **Premium Gift** | `buy_premium(username, months, show_sender=True)` | Username, duration, show sender | 3, 6, or 12 months |
|
||||
| **Stars Purchase** | `buy_stars(username, amount, show_sender=True)` | Username, Stars amount, show sender | 50–1,000,000 Stars |
|
||||
|
||||
Usernames can be passed with or without `@`.
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
from app.core.config import config
|
||||
from app.core.constants import (
|
||||
ADS_PAGE,
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
PREMIUM_PAGE,
|
||||
STARS_PAGE,
|
||||
WALLET_CLASSES,
|
||||
WalletVersion,
|
||||
)
|
||||
from app.core.cookies import load_cookies
|
||||
from app.core.exceptions import (
|
||||
ConfigError,
|
||||
CookiesError,
|
||||
FragmentError,
|
||||
HashFetchError,
|
||||
RequestError,
|
||||
TransactionError,
|
||||
UserNotFoundError,
|
||||
WalletError,
|
||||
)
|
||||
from app.core.logging import logger, setup_logging
|
||||
|
||||
__all__ = [
|
||||
"ADS_PAGE",
|
||||
"BASE_HEADERS",
|
||||
"DEVICE",
|
||||
"PREMIUM_PAGE",
|
||||
"STARS_PAGE",
|
||||
"WALLET_CLASSES",
|
||||
"WalletVersion",
|
||||
"ConfigError",
|
||||
"CookiesError",
|
||||
"FragmentError",
|
||||
"HashFetchError",
|
||||
"RequestError",
|
||||
"TransactionError",
|
||||
"UserNotFoundError",
|
||||
"WalletError",
|
||||
"config",
|
||||
"load_cookies",
|
||||
"logger",
|
||||
"setup_logging",
|
||||
]
|
||||
@@ -1,46 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app.core.constants import SUPPORTED_WALLET_VERSIONS, WalletVersion
|
||||
from app.core.exceptions import ConfigError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Config:
|
||||
SEED: str
|
||||
API_KEY: str
|
||||
WALLET_VERSION: WalletVersion
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Load .env if present; env vars already in the process take precedence
|
||||
env_path = Path(__file__).resolve().parents[2] / ".env"
|
||||
if env_path.exists():
|
||||
load_dotenv(env_path)
|
||||
|
||||
missing = [k for k in ("SEED", "API_KEY") if not os.getenv(k, "").strip()]
|
||||
if missing:
|
||||
raise ConfigError(
|
||||
f"Missing required environment variables: {', '.join(missing)}. "
|
||||
"Copy .env.example to .env and fill in SEED and API_KEY."
|
||||
)
|
||||
|
||||
self.SEED = os.getenv("SEED", "").strip()
|
||||
self.API_KEY = os.getenv("API_KEY", "").strip()
|
||||
|
||||
version = os.getenv("WALLET_VERSION", "V5R1").strip().upper()
|
||||
if version not in SUPPORTED_WALLET_VERSIONS:
|
||||
raise ConfigError(
|
||||
f"Unsupported WALLET_VERSION '{version}'. " f"Must be one of: {', '.join(sorted(SUPPORTED_WALLET_VERSIONS))}."
|
||||
)
|
||||
self.WALLET_VERSION: WalletVersion = version # type: ignore[assignment]
|
||||
|
||||
|
||||
config: Config | None = None
|
||||
try:
|
||||
config = Config()
|
||||
except ConfigError as e:
|
||||
logger.warning("Configuration not loaded: %s", e)
|
||||
@@ -1,32 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.exceptions import CookiesError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REQUIRED_KEYS = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
||||
|
||||
|
||||
def load_cookies() -> dict[str, Any]:
|
||||
cookies_path = Path(__file__).resolve().parents[2] / "cookies.json"
|
||||
|
||||
if not cookies_path.exists():
|
||||
raise CookiesError("cookies.json not found. Create it in the project root and paste your Fragment cookies.")
|
||||
|
||||
try:
|
||||
with cookies_path.open("r", encoding="utf-8") as f:
|
||||
cookies = json.load(f)
|
||||
except Exception as exc:
|
||||
raise CookiesError(f"Failed to read cookies.json: {exc}") from exc
|
||||
|
||||
missing = [k for k in _REQUIRED_KEYS if not str(cookies.get(k, "")).strip()]
|
||||
if missing:
|
||||
raise CookiesError(
|
||||
f"cookies.json is missing or has empty values for: {', '.join(missing)}. "
|
||||
"Open Fragment.com in your browser, copy fresh cookies, and update the file."
|
||||
)
|
||||
|
||||
return cookies
|
||||
@@ -1,42 +0,0 @@
|
||||
__all__ = [
|
||||
"ConfigError",
|
||||
"CookiesError",
|
||||
"FragmentError",
|
||||
"HashFetchError",
|
||||
"RequestError",
|
||||
"TransactionError",
|
||||
"UserNotFoundError",
|
||||
"WalletError",
|
||||
]
|
||||
|
||||
|
||||
class FragmentError(Exception):
|
||||
"""Base exception for all Fragment API errors."""
|
||||
|
||||
|
||||
class ConfigError(FragmentError):
|
||||
"""Raised when .env is missing or required keys are absent."""
|
||||
|
||||
|
||||
class CookiesError(FragmentError):
|
||||
"""Raised when cookies.json is missing, unreadable, or has empty required fields."""
|
||||
|
||||
|
||||
class HashFetchError(FragmentError):
|
||||
"""Raised when the Fragment API hash cannot be fetched from the page."""
|
||||
|
||||
|
||||
class UserNotFoundError(FragmentError):
|
||||
"""Raised when the target Telegram user is not found on Fragment."""
|
||||
|
||||
|
||||
class WalletError(FragmentError):
|
||||
"""Raised for TON wallet issues (connection, balance, account info)."""
|
||||
|
||||
|
||||
class TransactionError(FragmentError):
|
||||
"""Raised when a TON transaction fails to build or broadcast."""
|
||||
|
||||
|
||||
class RequestError(FragmentError):
|
||||
"""Raised when a Fragment API response cannot be parsed."""
|
||||
@@ -1,20 +0,0 @@
|
||||
import logging
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
formatter = logging.Formatter(fmt="[%(asctime)s] - %(levelname)s: %(message)s", datefmt="%d.%m.%y %H:%M:%S")
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
file_handler = logging.FileHandler("FragmentAPI.log", mode="w", encoding="utf-8")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(formatter)
|
||||
logging.basicConfig(level=logging.DEBUG, handlers=[console_handler, file_handler], force=True)
|
||||
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1,5 +0,0 @@
|
||||
from app.methods.premium import buy_premium
|
||||
from app.methods.stars import buy_stars
|
||||
from app.methods.ton import topup_ton
|
||||
|
||||
__all__ = ["buy_premium", "buy_stars", "topup_ton"]
|
||||
@@ -1,151 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import (
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
PREMIUM_PAGE,
|
||||
FragmentError,
|
||||
UserNotFoundError,
|
||||
load_cookies,
|
||||
)
|
||||
from app.utils import (
|
||||
execute_transaction_request,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
parse_json_response,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": PREMIUM_PAGE,
|
||||
"x-aj-referer": PREMIUM_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def search_premium_recipient(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
months: int,
|
||||
) -> str:
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={
|
||||
"query": username,
|
||||
"months": months,
|
||||
"method": "searchPremiumGiftRecipient",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "searchPremiumGiftRecipient")
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(
|
||||
f"Telegram user '{username}' was not found on Fragment. "
|
||||
"Make sure the username is correct and the account exists."
|
||||
)
|
||||
return recipient
|
||||
|
||||
|
||||
async def init_gift_premium(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
recipient: str,
|
||||
months: int,
|
||||
) -> str:
|
||||
await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={
|
||||
"mode": "new",
|
||||
"lv": "false",
|
||||
"dh": str(int(time.time())),
|
||||
"method": "updatePremiumState",
|
||||
},
|
||||
)
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={
|
||||
"recipient": recipient,
|
||||
"months": months,
|
||||
"method": "initGiftPremiumRequest",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "initGiftPremiumRequest")
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentError(
|
||||
"Fragment did not return a request ID for this Premium purchase. "
|
||||
"The session may have expired — refresh your cookies."
|
||||
)
|
||||
return req_id
|
||||
|
||||
|
||||
async def buy_premium(username: str, months: int, show_sender: bool = True) -> dict:
|
||||
if months not in (3, 6, 12):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Invalid duration. Choose 3, 6, or 12 months.",
|
||||
}
|
||||
|
||||
try:
|
||||
logger.info("Loading session cookies")
|
||||
cookies = load_cookies()
|
||||
|
||||
logger.info("Fetching Fragment session hash")
|
||||
fragment_hash = await get_fragment_hash(cookies, HEADERS, PREMIUM_PAGE)
|
||||
|
||||
# logger.info("Retrieving TON wallet info")
|
||||
account = await get_account_info()
|
||||
|
||||
async with httpx.AsyncClient(cookies=cookies) as client:
|
||||
logger.info("Searching recipient: %s", username)
|
||||
recipient = await search_premium_recipient(client, fragment_hash, username, months)
|
||||
|
||||
logger.info("Initializing Premium gift request: %s months to %s", months, username)
|
||||
req_id = await init_gift_premium(client, fragment_hash, recipient, months)
|
||||
|
||||
# logger.info("Requesting transaction payload (req_id=%s)", req_id)
|
||||
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(client, HEADERS, account, tx_data, fragment_hash)
|
||||
|
||||
logger.info("Broadcasting transaction to TON blockchain")
|
||||
tx_hash = await process_transaction(transaction)
|
||||
logger.info(
|
||||
"Premium purchase successful: %s months -> %s | tx: %s",
|
||||
months,
|
||||
username,
|
||||
tx_hash,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"transaction_id": tx_hash,
|
||||
"username": username,
|
||||
"months": months,
|
||||
"timestamp": int(time.time()),
|
||||
},
|
||||
}
|
||||
|
||||
except FragmentError as exc:
|
||||
logger.error("Premium purchase failed — %s", exc)
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error during Premium purchase")
|
||||
return {"success": False, "error": f"Unexpected error: {exc}"}
|
||||
@@ -1,133 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import (
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
STARS_PAGE,
|
||||
FragmentError,
|
||||
UserNotFoundError,
|
||||
load_cookies,
|
||||
)
|
||||
from app.utils import (
|
||||
execute_transaction_request,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
parse_json_response,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": STARS_PAGE,
|
||||
"x-aj-referer": STARS_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def search_stars_recipient(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
) -> str:
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={"query": username, "quantity": "", "method": "searchStarsRecipient"},
|
||||
)
|
||||
result = parse_json_response(resp, "searchStarsRecipient")
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(
|
||||
f"Telegram user '{username}' was not found on Fragment. "
|
||||
"Make sure the username is correct and the account exists."
|
||||
)
|
||||
return recipient
|
||||
|
||||
|
||||
async def init_buy_stars(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
recipient: str,
|
||||
amount: int,
|
||||
) -> str:
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={
|
||||
"recipient": recipient,
|
||||
"quantity": amount,
|
||||
"method": "initBuyStarsRequest",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "initBuyStarsRequest")
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentError(
|
||||
"Fragment did not return a request ID for this Stars purchase. "
|
||||
"The session may have expired — refresh your cookies."
|
||||
)
|
||||
return req_id
|
||||
|
||||
|
||||
async def buy_stars(username: str, amount: int, show_sender: bool = True) -> dict:
|
||||
if not isinstance(amount, int) or amount < 50:
|
||||
return {"success": False, "error": "Amount must be an integer >= 50 stars."}
|
||||
|
||||
try:
|
||||
logger.info("Loading session cookies")
|
||||
cookies = load_cookies()
|
||||
|
||||
logger.info("Fetching Fragment session hash")
|
||||
fragment_hash = await get_fragment_hash(cookies, HEADERS, STARS_PAGE)
|
||||
|
||||
# logger.info("Retrieving TON wallet info")
|
||||
account = await get_account_info()
|
||||
|
||||
async with httpx.AsyncClient(cookies=cookies) as client:
|
||||
logger.info("Searching recipient: %s", username)
|
||||
recipient = await search_stars_recipient(client, fragment_hash, username)
|
||||
|
||||
logger.info("Initializing Stars purchase request: %s stars to %s", amount, username)
|
||||
req_id = await init_buy_stars(client, fragment_hash, recipient, amount)
|
||||
|
||||
# logger.info("Requesting transaction payload (req_id=%s)", req_id)
|
||||
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(client, HEADERS, account, tx_data, fragment_hash)
|
||||
|
||||
logger.info("Broadcasting transaction to TON blockchain")
|
||||
tx_hash = await process_transaction(transaction)
|
||||
logger.info(
|
||||
"Stars purchase successful: %s stars -> %s | tx: %s",
|
||||
amount,
|
||||
username,
|
||||
tx_hash,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"transaction_id": tx_hash,
|
||||
"username": username,
|
||||
"amount": amount,
|
||||
"timestamp": int(time.time()),
|
||||
},
|
||||
}
|
||||
|
||||
except FragmentError as exc:
|
||||
logger.error("Stars purchase failed — %s", exc)
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error during Stars purchase")
|
||||
return {"success": False, "error": f"Unexpected error: {exc}"}
|
||||
@@ -1,132 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import (
|
||||
ADS_PAGE,
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
FragmentError,
|
||||
UserNotFoundError,
|
||||
load_cookies,
|
||||
)
|
||||
from app.utils import (
|
||||
execute_transaction_request,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
parse_json_response,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": ADS_PAGE,
|
||||
"x-aj-referer": ADS_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def search_ads_recipient(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
) -> str:
|
||||
await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={"mode": "new", "method": "updateAdsTopupState"},
|
||||
)
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={"query": username, "method": "searchAdsTopupRecipient"},
|
||||
)
|
||||
result = parse_json_response(resp, "searchAdsTopupRecipient")
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(
|
||||
f"Telegram user '{username}' was not found on Fragment. "
|
||||
"Make sure the username is correct and the account exists."
|
||||
)
|
||||
return recipient
|
||||
|
||||
|
||||
async def init_ads_topup(
|
||||
client: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
recipient: str,
|
||||
amount: int,
|
||||
) -> str:
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=HEADERS,
|
||||
data={
|
||||
"recipient": recipient,
|
||||
"amount": amount,
|
||||
"method": "initAdsTopupRequest",
|
||||
},
|
||||
)
|
||||
result = parse_json_response(resp, "initAdsTopupRequest")
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentError(
|
||||
"Fragment did not return a request ID for this TON topup. " "The session may have expired — refresh your cookies."
|
||||
)
|
||||
return req_id
|
||||
|
||||
|
||||
async def topup_ton(username: str, amount: int, show_sender: bool = True) -> dict:
|
||||
if not isinstance(amount, int) or amount < 1:
|
||||
return {"success": False, "error": "Amount must be an integer >= 1 TON."}
|
||||
|
||||
try:
|
||||
logger.info("Loading session cookies")
|
||||
cookies = load_cookies()
|
||||
|
||||
logger.info("Fetching Fragment session hash")
|
||||
fragment_hash = await get_fragment_hash(cookies, HEADERS, ADS_PAGE)
|
||||
|
||||
# logger.info("Retrieving TON wallet info")
|
||||
account = await get_account_info()
|
||||
|
||||
async with httpx.AsyncClient(cookies=cookies) as client:
|
||||
logger.info("Searching recipient: %s", username)
|
||||
recipient = await search_ads_recipient(client, fragment_hash, username)
|
||||
|
||||
logger.info("Initializing topup request: %s TON to %s", amount, username)
|
||||
req_id = await init_ads_topup(client, fragment_hash, recipient, amount)
|
||||
|
||||
# logger.info("Requesting transaction payload (req_id=%s)", req_id)
|
||||
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(client, HEADERS, account, tx_data, fragment_hash)
|
||||
|
||||
logger.info("Broadcasting transaction to TON blockchain")
|
||||
tx_hash = await process_transaction(transaction)
|
||||
logger.info("TON topup successful: %s TON -> %s | tx: %s", amount, username, tx_hash)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"transaction_id": tx_hash,
|
||||
"username": username,
|
||||
"amount": amount,
|
||||
"timestamp": int(time.time()),
|
||||
},
|
||||
}
|
||||
|
||||
except FragmentError as exc:
|
||||
logger.error("TON topup failed — %s", exc)
|
||||
return {"success": False, "error": str(exc)}
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error during TON topup")
|
||||
return {"success": False, "error": f"Unexpected error: {exc}"}
|
||||
@@ -1,14 +0,0 @@
|
||||
from app.utils.client import execute_transaction_request, parse_json_response
|
||||
from app.utils.decoder import clean_decode
|
||||
from app.utils.hash import get_fragment_hash
|
||||
from app.utils.wallet import get_account_info, link_wallet, process_transaction
|
||||
|
||||
__all__ = [
|
||||
"clean_decode",
|
||||
"execute_transaction_request",
|
||||
"get_account_info",
|
||||
"get_fragment_hash",
|
||||
"link_wallet",
|
||||
"parse_json_response",
|
||||
"process_transaction",
|
||||
]
|
||||
@@ -1,39 +0,0 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import RequestError, WalletError
|
||||
from app.utils.wallet import link_wallet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as exc:
|
||||
raise RequestError(f"Fragment API returned an unparseable response for '{context}': {exc}") from exc
|
||||
|
||||
|
||||
async def execute_transaction_request(
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict,
|
||||
account: dict[str, Any],
|
||||
tx_data: dict[str, Any],
|
||||
fragment_hash: str,
|
||||
) -> dict[str, Any]:
|
||||
url = f"https://fragment.com/api?hash={fragment_hash}"
|
||||
|
||||
resp = await client.post(url, headers=headers, data=tx_data)
|
||||
transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
|
||||
|
||||
if transaction.get("need_verify"):
|
||||
if not await link_wallet(client, headers, account, fragment_hash):
|
||||
raise WalletError(
|
||||
"Failed to link your TON wallet to Fragment. " "Make sure the wallet matching your cookies is used."
|
||||
)
|
||||
resp = await client.post(url, headers=headers, data=tx_data)
|
||||
transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
|
||||
|
||||
return transaction
|
||||
@@ -1,36 +0,0 @@
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from pytoniq_core import Cell
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# OLD decoder (manual base64 + regex, kept for reference):
|
||||
#
|
||||
# import re, string
|
||||
# def clean_decode(payload: str) -> str:
|
||||
# s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip())
|
||||
# s += '=' * (-len(s) % 4)
|
||||
# text = base64.b64decode(s).decode('utf-8', errors='ignore')
|
||||
# text = ''.join(c for c in text if c in string.printable or c.isspace())
|
||||
# match = re.search(r'([0-9]*\s*Telegram .*?Ref#[A-Za-z0-9]+)', text, re.S)
|
||||
# return match.group(1).strip() if match else text.strip()
|
||||
|
||||
|
||||
def clean_decode(payload: str) -> str:
|
||||
# Pad and decode base64 → BOC bytes
|
||||
s = payload.strip()
|
||||
if not s:
|
||||
return ""
|
||||
s += "=" * (-len(s) % 4)
|
||||
boc = base64.b64decode(s)
|
||||
|
||||
# Parse BOC cell and read snake-encoded text (skipping 32-bit op prefix)
|
||||
cell = Cell.one_from_boc(boc)
|
||||
sl = cell.begin_parse()
|
||||
sl.load_uint(32) # op code — always 0 for text comment
|
||||
result = sl.load_snake_string().strip()
|
||||
|
||||
logger.debug("Payload: %s -> %s", payload, result.replace("\n", " "))
|
||||
return result
|
||||
@@ -1,57 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core import HashFetchError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_fragment_hash(
|
||||
cookies: dict[str, Any],
|
||||
headers: dict[str, str],
|
||||
page_url: str,
|
||||
) -> str:
|
||||
# Must look like a real browser navigation — not an XHR — otherwise Fragment
|
||||
# returns JSON (no hash in it) instead of full 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 client:
|
||||
response = await client.get(page_url, headers=page_headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HashFetchError(
|
||||
f"Fragment returned HTTP {response.status_code} for {page_url}. "
|
||||
"Check that your cookies are valid and not expired."
|
||||
)
|
||||
|
||||
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
|
||||
if not match:
|
||||
raise HashFetchError(
|
||||
f"Fragment hash not found in the page source of {page_url}. "
|
||||
"The page structure may have changed or you are not logged in."
|
||||
)
|
||||
|
||||
return match.group(1)
|
||||
@@ -1,107 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from tonutils.clients import TonapiClient
|
||||
from tonutils.types import NetworkGlobalID
|
||||
|
||||
from app.core import DEVICE, WALLET_CLASSES, TransactionError, WalletError, config
|
||||
from app.utils.decoder import clean_decode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def initialize_ton_client() -> TonapiClient:
|
||||
return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=config.API_KEY)
|
||||
|
||||
|
||||
async def process_transaction(transaction_data: dict) -> str:
|
||||
logger.debug("transaction_data: %s", transaction_data)
|
||||
|
||||
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
|
||||
raise TransactionError(
|
||||
"Fragment returned an invalid transaction payload. "
|
||||
"The API response is missing expected 'transaction.messages' data."
|
||||
)
|
||||
|
||||
# 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 initialize_ton_client() as client:
|
||||
wallet_cls = WALLET_CLASSES[config.WALLET_VERSION]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.SEED)
|
||||
|
||||
# Check balance before broadcasting
|
||||
try:
|
||||
await wallet.refresh()
|
||||
balance_ton = wallet.balance / 1_000_000_000
|
||||
if balance_ton < 0.056:
|
||||
raise WalletError(f"TON wallet balance is too low: {balance_ton:.2f} TON. " "Minimum required is 0.056 TON.")
|
||||
except WalletError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise WalletError(f"Wallet balance check failed: {exc}") from exc
|
||||
|
||||
try:
|
||||
message = transaction_data["transaction"]["messages"][0]
|
||||
payload = clean_decode(message["payload"])
|
||||
|
||||
result = await wallet.transfer(
|
||||
destination=message["address"],
|
||||
amount=int(message["amount"]), # nanotons, not TON
|
||||
body=payload,
|
||||
)
|
||||
tx_hash = result.normalized_hash
|
||||
return tx_hash
|
||||
except (WalletError, TransactionError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TransactionError(f"Transaction broadcast failed: {exc}") from exc
|
||||
|
||||
|
||||
async def get_account_info() -> dict[str, Any]:
|
||||
async with initialize_ton_client() as client:
|
||||
try:
|
||||
wallet_cls = WALLET_CLASSES[config.WALLET_VERSION]
|
||||
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=client, mnemonic=config.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(f"Failed to retrieve wallet account info: {exc}") from exc
|
||||
|
||||
|
||||
async def link_wallet(
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict,
|
||||
account: dict[str, Any],
|
||||
fragment_hash: str,
|
||||
) -> bool:
|
||||
resp = await client.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=headers,
|
||||
data={
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"method": "linkWallet",
|
||||
},
|
||||
)
|
||||
result = resp.json()
|
||||
|
||||
if result.get("ok"):
|
||||
return True
|
||||
|
||||
if "transaction" in result:
|
||||
try:
|
||||
await process_transaction(result)
|
||||
return True
|
||||
except (TransactionError, WalletError):
|
||||
return False
|
||||
|
||||
return False
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"stel_ssid": "",
|
||||
"stel_dt": "",
|
||||
"stel_token": "",
|
||||
"stel_ton_token": ""
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
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())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
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())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
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())
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
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,66 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.core import setup_logging
|
||||
from app.methods import buy_premium, buy_stars, topup_ton
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def topup_ton_example():
|
||||
logger.info("Starting TON topup example")
|
||||
|
||||
# @bohd4nx - target username, 100 - TON amount (integer 1-1000000000 (one billion))
|
||||
# show_sender=True — recipient sees who sent the topup
|
||||
result = await topup_ton("@bohd4nx", 100, show_sender=True)
|
||||
|
||||
if result["success"]:
|
||||
pass # Transaction successful, details are logged in the method
|
||||
else:
|
||||
logger.error(f"TON topup failed: {result['error']}")
|
||||
|
||||
|
||||
async def buy_premium_example():
|
||||
logger.info("Starting Premium purchase example")
|
||||
|
||||
# @bohd4nx - target username, 12 - months duration (3, 6, or 12 only)
|
||||
# show_sender=True — recipient sees who gifted the Premium
|
||||
result = await buy_premium("@bohd4nx", 12, show_sender=True)
|
||||
|
||||
if result["success"]:
|
||||
pass # Transaction successful, details are logged in the method
|
||||
else:
|
||||
logger.error(f"Premium purchase failed: {result['error']}")
|
||||
|
||||
|
||||
async def buy_stars_example():
|
||||
logger.info("Starting Stars purchase example")
|
||||
|
||||
# @bohd4nx - target username, 1000000 - stars amount (integer 50-1000000 (one million))
|
||||
# show_sender=True — recipient sees who sent the Stars
|
||||
result = await buy_stars("@bohd4nx", 1000000, show_sender=True)
|
||||
|
||||
if result["success"]:
|
||||
pass # Transaction successful, details are logged in the method
|
||||
else:
|
||||
logger.error(f"Stars purchase failed: {result['error']}")
|
||||
|
||||
|
||||
async def main():
|
||||
setup_logging()
|
||||
logger.info("Starting Fragment API by @bohd4nx - examples")
|
||||
|
||||
await topup_ton_example()
|
||||
await buy_premium_example()
|
||||
await buy_stars_example()
|
||||
|
||||
logger.info("All examples completed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Fragment API by @bohd4nx - Usage Examples")
|
||||
logger.info("Supported username formats: @username, username")
|
||||
logger.info("Limits: TON minimum 1, Premium 3/6/12 months, Stars minimum 50")
|
||||
logger.info("Setup: Copy .env.example to .env and fill all fields")
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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",
|
||||
]
|
||||
@@ -0,0 +1,146 @@
|
||||
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)
|
||||
@@ -0,0 +1,5 @@
|
||||
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"]
|
||||
@@ -0,0 +1,120 @@
|
||||
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
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
@@ -0,0 +1,107 @@
|
||||
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
|
||||
@@ -0,0 +1,62 @@
|
||||
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",
|
||||
]
|
||||
@@ -10,10 +10,16 @@ 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"
|
||||
ADS_PAGE: str = "https://fragment.com/ads/topup"
|
||||
TON_PAGE: str = "https://fragment.com/ads/topup"
|
||||
|
||||
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
|
||||
DEVICE: str = json.dumps(
|
||||
@@ -0,0 +1,109 @@
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
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}')"
|
||||
@@ -0,0 +1,18 @@
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
@@ -0,0 +1,135 @@
|
||||
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
|
||||
+53
-2
@@ -1,3 +1,52 @@
|
||||
[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"]
|
||||
@@ -9,11 +58,13 @@ line-length = 128
|
||||
target-version = ["py312"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 128
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort
|
||||
select = ["E", "F", "W", "I"]
|
||||
# E501 — line too long (covered by line-length above)
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["E402"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-e .[dev]
|
||||
+1
-4
@@ -1,4 +1 @@
|
||||
python-dotenv==1.2.2
|
||||
asyncio==4.0.0
|
||||
httpx==0.28.1
|
||||
tonutils[pytoniq]==2.0.0
|
||||
-e .
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to
|
||||
human-readable UTF-8 with the Telegram label and Ref# intact."""
|
||||
"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to UTF-8."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from app.utils.decoder import clean_decode
|
||||
from pyfragment.types import ParseError
|
||||
from pyfragment.utils.decoder import clean_decode
|
||||
|
||||
PAYLOADS = [
|
||||
pytest.param(
|
||||
@@ -24,12 +24,17 @@ PAYLOADS = [
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", PAYLOADS)
|
||||
def test_payload(payload: str) -> None:
|
||||
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) <= 127 for c in result), f"non-ASCII chars in {result!r}"
|
||||
assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}"
|
||||
|
||||
|
||||
def test_empty_input_returns_string() -> None:
|
||||
assert isinstance(clean_decode(""), str)
|
||||
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!!!")
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""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)
|
||||
@@ -5,8 +5,8 @@ import re
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.constants import BASE_HEADERS, STARS_PAGE
|
||||
from app.utils.hash import get_fragment_hash
|
||||
from pyfragment.types import BASE_HEADERS, STARS_PAGE
|
||||
from pyfragment.utils import get_fragment_hash
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -0,0 +1,127 @@
|
||||
"""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": {}})
|
||||
@@ -0,0 +1,65 @@
|
||||
"""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)
|
||||
+10
-6
@@ -1,13 +1,17 @@
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.cookies import load_cookies
|
||||
from app.core.exceptions import CookiesError
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cookies():
|
||||
"""Load Fragment cookies; skip the test if they are unavailable."""
|
||||
"""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:
|
||||
return load_cookies()
|
||||
except CookiesError as exc:
|
||||
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