diff --git a/.env.example b/.env.example deleted file mode 100644 index 806b019..0000000 --- a/.env.example +++ /dev/null @@ -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" diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000..8006d94 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ea24733 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature.yaml b/.github/ISSUE_TEMPLATE/feature.yaml new file mode 100644 index 0000000..e2a26c4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -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. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e00c570 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 25b94b6..6388e89 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57e35cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1b575e9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index d698a55..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 9cb8f25..cda7104 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e9ff67c --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a85f6ec --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 280f9a1..ccb7c8b 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,117 @@
Fragment Logo -

💎 Fragment API by @bohd4nx

+

Fragment API

- Automate TON topups, Telegram Premium purchases, and Stars transactions via Fragment.com + Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance.

-[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) -[![tonutils](https://img.shields.io/badge/tonutils-2.0.0-0098EA?style=flat&logo=ton&logoColor=white)](https://github.com/nessshon/tonutils) -[![Stars](https://img.shields.io/github/stars/bohd4nx/FragmentAPI?style=flat&color=yellow)](https://github.com/bohd4nx/FragmentAPI/stargazers) -[![Issues](https://img.shields.io/github/issues/bohd4nx/FragmentAPI?style=flat&color=red)](https://github.com/bohd4nx/FragmentAPI/issues) -[![CI](https://img.shields.io/github/actions/workflow/status/bohd4nx/FragmentAPI/tests.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/FragmentAPI/actions) +[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) +[![PyPI downloads](https://img.shields.io/pypi/dm/pyfragment?style=flat&color=brightgreen)](https://pypi.org/project/pyfragment/) +[![Python](https://img.shields.io/badge/Python-3.12+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) +[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=lightgrey)](LICENSE) +[![Stars](https://img.shields.io/github/stars/bohd4nx/pyfragment?style=flat&color=yellow)](https://github.com/bohd4nx/pyfragment/stargazers) +[![CI](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions) -[Report Bug](https://github.com/bohd4nx/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)
+> **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": "", - "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 `@`. +---
diff --git a/app/core/__init__.py b/app/core/__init__.py deleted file mode 100644 index c31b827..0000000 --- a/app/core/__init__.py +++ /dev/null @@ -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", -] diff --git a/app/core/config.py b/app/core/config.py deleted file mode 100644 index 52811ee..0000000 --- a/app/core/config.py +++ /dev/null @@ -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) diff --git a/app/core/cookies.py b/app/core/cookies.py deleted file mode 100644 index 4f43be0..0000000 --- a/app/core/cookies.py +++ /dev/null @@ -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 diff --git a/app/core/exceptions.py b/app/core/exceptions.py deleted file mode 100644 index 9a59b65..0000000 --- a/app/core/exceptions.py +++ /dev/null @@ -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.""" diff --git a/app/core/logging.py b/app/core/logging.py deleted file mode 100644 index f701f52..0000000 --- a/app/core/logging.py +++ /dev/null @@ -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__) diff --git a/app/methods/__init__.py b/app/methods/__init__.py deleted file mode 100644 index 590e0e7..0000000 --- a/app/methods/__init__.py +++ /dev/null @@ -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"] diff --git a/app/methods/premium.py b/app/methods/premium.py deleted file mode 100644 index 798bc6e..0000000 --- a/app/methods/premium.py +++ /dev/null @@ -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}"} diff --git a/app/methods/stars.py b/app/methods/stars.py deleted file mode 100644 index af21079..0000000 --- a/app/methods/stars.py +++ /dev/null @@ -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}"} diff --git a/app/methods/ton.py b/app/methods/ton.py deleted file mode 100644 index ee5d49f..0000000 --- a/app/methods/ton.py +++ /dev/null @@ -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}"} diff --git a/app/utils/__init__.py b/app/utils/__init__.py deleted file mode 100644 index b2d51d5..0000000 --- a/app/utils/__init__.py +++ /dev/null @@ -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", -] diff --git a/app/utils/client.py b/app/utils/client.py deleted file mode 100644 index 2ed4624..0000000 --- a/app/utils/client.py +++ /dev/null @@ -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 diff --git a/app/utils/decoder.py b/app/utils/decoder.py deleted file mode 100644 index 2d52926..0000000 --- a/app/utils/decoder.py +++ /dev/null @@ -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 diff --git a/app/utils/hash.py b/app/utils/hash.py deleted file mode 100644 index bf4f380..0000000 --- a/app/utils/hash.py +++ /dev/null @@ -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) diff --git a/app/utils/wallet.py b/app/utils/wallet.py deleted file mode 100644 index 96ca4f4..0000000 --- a/app/utils/wallet.py +++ /dev/null @@ -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 diff --git a/cookies.example.json b/cookies.example.json deleted file mode 100644 index 7e8b0d5..0000000 --- a/cookies.example.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "stel_ssid": "", - "stel_dt": "", - "stel_token": "", - "stel_ton_token": "" -} diff --git a/examples/get_wallet.py b/examples/get_wallet.py new file mode 100644 index 0000000..936636e --- /dev/null +++ b/examples/get_wallet.py @@ -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()) diff --git a/examples/purchase_premium.py b/examples/purchase_premium.py new file mode 100644 index 0000000..d4366f2 --- /dev/null +++ b/examples/purchase_premium.py @@ -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()) diff --git a/examples/purchase_stars.py b/examples/purchase_stars.py new file mode 100644 index 0000000..c0902a4 --- /dev/null +++ b/examples/purchase_stars.py @@ -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()) diff --git a/examples/topup_ton.py b/examples/topup_ton.py new file mode 100644 index 0000000..f362256 --- /dev/null +++ b/examples/topup_ton.py @@ -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()) diff --git a/main.py b/main.py deleted file mode 100644 index 4c85964..0000000 --- a/main.py +++ /dev/null @@ -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()) diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py new file mode 100644 index 0000000..d08f26e --- /dev/null +++ b/pyfragment/__init__.py @@ -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", +] diff --git a/pyfragment/client.py b/pyfragment/client.py new file mode 100644 index 0000000..09a62df --- /dev/null +++ b/pyfragment/client.py @@ -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) diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py new file mode 100644 index 0000000..c0f4e2e --- /dev/null +++ b/pyfragment/methods/__init__.py @@ -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"] diff --git a/pyfragment/methods/premium.py b/pyfragment/methods/premium.py new file mode 100644 index 0000000..0c19c6b --- /dev/null +++ b/pyfragment/methods/premium.py @@ -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 diff --git a/pyfragment/methods/stars.py b/pyfragment/methods/stars.py new file mode 100644 index 0000000..bc58fa8 --- /dev/null +++ b/pyfragment/methods/stars.py @@ -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 diff --git a/pyfragment/methods/ton.py b/pyfragment/methods/ton.py new file mode 100644 index 0000000..ef5871b --- /dev/null +++ b/pyfragment/methods/ton.py @@ -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 diff --git a/pyfragment/py.typed b/pyfragment/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py new file mode 100644 index 0000000..c254997 --- /dev/null +++ b/pyfragment/types/__init__.py @@ -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", +] diff --git a/app/core/constants.py b/pyfragment/types/constants.py similarity index 86% rename from app/core/constants.py rename to pyfragment/types/constants.py index 243edb5..2bb799d 100644 --- a/app/core/constants.py +++ b/pyfragment/types/constants.py @@ -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( diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py new file mode 100644 index 0000000..435a50c --- /dev/null +++ b/pyfragment/types/exceptions.py @@ -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", +] diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py new file mode 100644 index 0000000..a6ad138 --- /dev/null +++ b/pyfragment/types/results.py @@ -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}')" diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py new file mode 100644 index 0000000..59d3693 --- /dev/null +++ b/pyfragment/utils/__init__.py @@ -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", +] diff --git a/pyfragment/utils/decoder.py b/pyfragment/utils/decoder.py new file mode 100644 index 0000000..451a7b6 --- /dev/null +++ b/pyfragment/utils/decoder.py @@ -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 diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py new file mode 100644 index 0000000..a1a5e1f --- /dev/null +++ b/pyfragment/utils/http.py @@ -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 diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py new file mode 100644 index 0000000..55680f2 --- /dev/null +++ b/pyfragment/utils/wallet.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 48cb023..1f41d60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..aefbcb6 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +-e .[dev] diff --git a/requirements.txt b/requirements.txt index ab531a7..d6e1198 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1 @@ -python-dotenv==1.2.2 -asyncio==4.0.0 -httpx==0.28.1 -tonutils[pytoniq]==2.0.0 +-e . diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 408de53..170fa1e 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -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!!!") diff --git a/tests/002_test_client.py b/tests/002_test_client.py new file mode 100644 index 0000000..ccf10fe --- /dev/null +++ b/tests/002_test_client.py @@ -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) diff --git a/tests/002_test_hash.py b/tests/003_test_hash.py similarity index 82% rename from tests/002_test_hash.py rename to tests/003_test_hash.py index b1b50ec..4bb6f18 100644 --- a/tests/002_test_hash.py +++ b/tests/003_test_hash.py @@ -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 diff --git a/tests/004_test_balance.py b/tests/004_test_balance.py new file mode 100644 index 0000000..e2023ee --- /dev/null +++ b/tests/004_test_balance.py @@ -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": {}}) diff --git a/tests/005_test_methods.py b/tests/005_test_methods.py new file mode 100644 index 0000000..5a7f019 --- /dev/null +++ b/tests/005_test_methods.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index ac14129..9b60a88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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}")