Merge pull request #30 from bohd4nx/dev

Dev
This commit is contained in:
Bohdan
2026-06-27 03:05:52 +03:00
committed by GitHub
10 changed files with 20 additions and 249 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-python@v6 - uses: actions/setup-python@v6
with: with:
python-version: "3.10" python-version: "3.11"
cache: pip cache: pip
- run: pip install ".[dev]" - run: pip install ".[dev]"
@@ -30,7 +30,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14"] python-version: ["3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
-58
View File
@@ -1,58 +0,0 @@
# Contributing to pyfragment
## Development setup
```bash
git clone https://github.com/bohd4nx/pyfragment.git
cd pyfragment
pip install -e ".[dev]"
```
## Running checks
```bash
# Lint and format
ruff check . --fix && ruff format .
# Type check
mypy . --explicit-package-bases
# Tests
pytest
```
All three must pass before opening a PR.
## Project structure
```
pyfragment/
client.py — FragmentClient (public entry point)
enums.py — ApiProvider, PaymentMethod, WalletVersion
exceptions.py — exception hierarchy
core/ — constants, validation helpers
domains/ — one package per feature domain
ads/ — recharge_ads, topup_gram
anonymous_numbers/— get_login_code, toggle_login_codes, terminate_sessions
giveaways/ — giveaway_stars, giveaway_premium
marketplace/ — search_usernames, search_numbers, search_gifts
purchases/ — purchase_stars, purchase_premium
services/ — shared infrastructure services
cookies/ — browser cookie extraction (models + service)
tonapi/ — wallet info, transaction signing (tonapi/toncenter)
tests/ — unit tests (pytest)
examples/ — runnable usage examples (excluded from CI)
```
## Conventions
- All public async methods live on `FragmentClient` and delegate to a domain service.
- Domain functions receive a `FragmentClient` instance, never raw httpx clients.
- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.services.tonapi.transaction._make_ton_client`.
- Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`.
## Pull requests
- Keep PRs focused — one feature or fix per PR.
- Update `CHANGELOG.md` under `[Unreleased]`.
- Add or update tests for any changed behaviour.
+7 -107
View File
@@ -9,18 +9,14 @@
[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=Tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions) [![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=Tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE) [![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE)
Async Python client for the **[Fragment](https://fragment.com)** marketplace API. Async Python client for the **[Fragment.com](https://fragment.com)** marketplace API.
Buy Stars & Premium, run giveaways, top up GRAM (ex TON) and Ads balances,
manage anonymous numbers, and search Fragment listings.
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate GRAM](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)** **[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)**
</div> </div>
> **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org). > **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
---
## Installation ## Installation
```bash ```bash
@@ -46,120 +42,24 @@ from pyfragment.enums import PaymentMethod
async def main() -> None: async def main() -> None:
async with FragmentClient( async with FragmentClient(
seed="word1 word2 ... word24", seed="word1 word2 ... word24",
api_key="YOUR_API_KEY", # tonconsole.com (tonapi, default) or t.me/toncenter api_key="YOUR_API_KEY",
cookies={ cookies={"stel_ssid": "...", "stel_dt": "...", "stel_token": "...", "stel_ton_token": "..."},
"stel_ssid": "...",
"stel_dt": "...",
"stel_token": "...",
"stel_ton_token": "...",
},
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
) as client: ) as client:
wallet = await client.get_wallet() wallet = await client.get_wallet()
print(f"GRAM: {wallet.gram_balance} | USDT: {wallet.usdt_balance}") print(f"GRAM: {wallet.gram_balance} | USDT: {wallet.usdt_balance}")
recipient = "https://t.me/username" # also: @username, username stars = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.USDT_GRAM)
stars = await client.purchase_stars(recipient, amount=500, payment_method=PaymentMethod.USDT_GRAM)
print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}") print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}")
premium = await client.purchase_premium(recipient, months=6, payment_method=PaymentMethod.GRAM) premium = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM)
print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}") print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}")
asyncio.run(main()) asyncio.run(main())
``` ```
---
## Configuration
| Parameter | Type | Default | Description |
| ---------------- | ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- |
| `seed` | `str` | — | 12- or 24-word GRAM (ex TON) wallet mnemonic |
| `api_key` | `str` | — | API key for the chosen provider (see `api_provider`) |
| `cookies` | `dict \| str` | — | Fragment session cookies |
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal |
| `api_provider` | `str` | `"tonapi"` | `"tonapi"` ([tonconsole.com](https://tonconsole.com)) or `"toncenter"` ([t.me/toncenter](https://t.me/toncenter)) |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
---
## Credentials
### Fragment cookies
Log in to [fragment.com](https://fragment.com) and connect your GRAM (ex TON) wallet.
**Automatically** (recommended) — reads directly from your browser, no extension needed:
```bash
pip install "pyfragment[browser]"
```
```python
from pyfragment import get_cookies_from_browser
result = get_cookies_from_browser("chrome") # firefox, edge, brave, ...
# result.cookies — dict[str, str] ready to pass to FragmentClient
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
```
**Manually** — use [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass as a `dict` or JSON string.
Refresh cookies when you get authentication errors.
### Tonapi key
Generate at [tonconsole.com](https://tonconsole.com).
### Seed phrase
12- or 24-word mnemonic from your GRAM (ex TON) wallet (**Tonkeeper → Settings → Backup**). Never share it.
---
## Logging
`pyfragment` uses the standard `logging` module under the `pyfragment` namespace and is silent by default:
```python
import logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("pyfragment").setLevel(logging.DEBUG) # for detailed request logs
```
---
## Error Handling
All exceptions inherit from `FragmentError`:
```python
from pyfragment import (
ConfigurationError, # invalid arguments (amount, months, payment_method…)
UserNotFoundError, # recipient not found on Fragment
WalletError, # insufficient GRAM (ex TON) or USDT balance
TransactionError, # broadcast failed, duplicate seqno, invalid payload
FragmentAPIError, # Fragment API returned an error response
FragmentPageError, # page fetch or hash extraction failed
AnonymousNumberError, # number not owned, wrong state, login code issues
CookieError, # missing or malformed session cookies
ParseError, # failed to decode Fragment payload
VerificationError, # on-chain verification step failed
OperationError, # generic operation-level failure
UnexpectedError, # unexpected API response structure
)
```
---
<div align="center"> <div align="center">
Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
**Star ⭐ if you found it useful**
</div> </div>
-19
View File
@@ -1,19 +0,0 @@
# Security Policy
## Reporting a vulnerability
Please **do not** open a public GitHub issue for security vulnerabilities.
Report them privately via GitHub's [Security Advisory](https://github.com/bohd4nx/pyfragment/security/advisories/new) feature, or contact the maintainer directly at [@bohd4nx](https://t.me/bohd4nx) on Telegram.
Include:
- A description of the vulnerability and its potential impact.
- Steps to reproduce or a proof-of-concept.
- Affected versions.
You will receive a response within 72 hours. Once the fix is released, the advisory will be published.
## Scope
This library handles sensitive credentials (GRAM (ex TON) seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity.
+1 -15
View File
@@ -8,7 +8,7 @@ from typing import Any, cast
import httpx import httpx
from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
from pyfragment.exceptions import FragmentPageError, ParseError, VerificationError from pyfragment.exceptions import FragmentPageError, ParseError
async def get_fragment_hash( async def get_fragment_hash(
@@ -73,17 +73,3 @@ async def fragment_request(
) )
return parse_json_response(resp, data.get("method", "request")) return parse_json_response(resp, data.get("method", "request"))
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api")) raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
async def execute_transaction_request(
session: httpx.AsyncClient,
headers: dict[str, str],
tx_data: dict[str, Any],
fragment_hash: str,
) -> dict[str, Any]:
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
return transaction
+1 -9
View File
@@ -1,18 +1,10 @@
from __future__ import annotations from __future__ import annotations
import sys from enum import StrEnum
from enum import Enum
from typing import Any from typing import Any
from tonutils.contracts.wallet import WalletHighloadV2, WalletHighloadV3R1, WalletV4R2, WalletV5R1 from tonutils.contracts.wallet import WalletHighloadV2, WalletHighloadV3R1, WalletV4R2, WalletV5R1
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
class StrEnum(str, Enum): # noqa: F811
pass
class PaymentMethod(StrEnum): class PaymentMethod(StrEnum):
GRAM = "ton" GRAM = "ton"
-4
View File
@@ -36,10 +36,6 @@ class ConfigurationError(ClientError):
f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}." f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}."
) )
INVALID_GRAM_AMOUNT = f"Invalid GRAM (ex TON) amount: must be an integer between {GRAM_TOPUP_MIN:,} and {GRAM_TOPUP_MAX:,}." INVALID_GRAM_AMOUNT = f"Invalid GRAM (ex TON) amount: must be an integer between {GRAM_TOPUP_MIN:,} and {GRAM_TOPUP_MAX:,}."
INVALID_USERNAME = (
"Invalid username '{username}'. "
"Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)."
)
INVALID_WINNERS_STARS = ( INVALID_WINNERS_STARS = (
f"Invalid winners count: must be an integer between {STARS_WINNERS_MIN:,} and {STARS_WINNERS_MAX:,}." f"Invalid winners count: must be an integer between {STARS_WINNERS_MIN:,} and {STARS_WINNERS_MAX:,}."
) )
+4 -4
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import importlib import importlib
from datetime import datetime, timezone from datetime import UTC, datetime
from typing import Any from typing import Any
from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS
@@ -42,11 +42,11 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
if cookie.get("name") == "stel_ssid": if cookie.get("name") == "stel_ssid":
raw = cookie.get("expires") raw = cookie.get("expires")
if isinstance(raw, (int, float)): if isinstance(raw, (int, float)):
expires_iso = datetime.fromtimestamp(raw, tz=timezone.utc).isoformat() expires_iso = datetime.fromtimestamp(raw, tz=UTC).isoformat()
elif isinstance(raw, str) and raw: elif isinstance(raw, str) and raw:
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"): for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
try: try:
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc).isoformat() expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=UTC).isoformat()
break break
except ValueError: except ValueError:
continue continue
@@ -54,7 +54,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
if expires_iso: if expires_iso:
expires_dt = datetime.fromisoformat(expires_iso) expires_dt = datetime.fromisoformat(expires_iso)
if expires_dt < datetime.now(timezone.utc): if expires_dt < datetime.now(UTC):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso)) raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso) return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso)
+5 -27
View File
@@ -5,51 +5,33 @@ build-backend = "hatchling.build"
[project] [project]
name = "pyfragment" name = "pyfragment"
version = "2026.3.2" version = "2026.3.2"
description = "Async Python client for the Fragment API. Buy Stars and Premium, top up GRAM (ex TON) and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings." description = "Async Python client for the Fragment.com marketplace API."
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
requires-python = ">=3.10" requires-python = ">=3.11"
authors = [{ name = "bohd4nx" }] authors = [{ name = "bohd4nx" }]
keywords = [ keywords = [
"fragment", "fragment",
"fragment-api",
"telegram", "telegram",
"telegram-api",
"telegram-stars", "telegram-stars",
"telegram-premium", "telegram-premium",
"telegram-giveaway",
"telegram-ads",
"ton", "ton",
"ton-blockchain",
"tonapi", "tonapi",
"anonymous-numbers", "anonymous-numbers",
"username-auctions",
"gift-marketplace",
"crypto-payments",
"nft-marketplace",
"web3",
"python-client",
"typed",
"asyncio", "asyncio",
] ]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.14",
"Framework :: AsyncIO", "Framework :: AsyncIO",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet",
"Topic :: Internet :: WWW/HTTP",
"Typing :: Typed", "Typing :: Typed",
] ]
dependencies = ["httpx>=0.25", "tonutils>=2.0.1"] dependencies = ["httpx>=0.25", "tonutils>=2.0.1"]
@@ -59,7 +41,6 @@ dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
browser = ["rookiepy>=0.5.6"] browser = ["rookiepy>=0.5.6"]
[project.urls] [project.urls]
Homepage = "https://github.com/bohd4nx/pyfragment"
Repository = "https://github.com/bohd4nx/pyfragment" Repository = "https://github.com/bohd4nx/pyfragment"
Documentation = "https://bohd4nx.gitbook.io/pyfragment/" Documentation = "https://bohd4nx.gitbook.io/pyfragment/"
Issues = "https://github.com/bohd4nx/pyfragment/issues" Issues = "https://github.com/bohd4nx/pyfragment/issues"
@@ -76,20 +57,17 @@ addopts = "-v --tb=short"
[tool.ruff] [tool.ruff]
line-length = 128 line-length = 128
target-version = "py310" target-version = "py311"
[tool.ruff.lint] [tool.ruff.lint]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
select = ["E", "F", "W", "I", "UP"] select = ["E", "F", "W", "I", "UP"]
# E501 — line too long (covered by line-length above) ignore = ["E501"]
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
ignore = ["E501", "UP017"]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"] "tests/*" = ["E402"]
[tool.mypy] [tool.mypy]
python_version = "3.10" python_version = "3.11"
strict = true strict = true
exclude = ["^examples/", "^logs/", "^tests/"] exclude = ["^examples/", "^logs/", "^tests/"]
-4
View File
@@ -36,10 +36,6 @@ FAKE_BALANCE_NANOGRAM: int = 1_500_000_000 # 1.5 GRAM (ex TON)
# recharge_ads # recharge_ads
FAKE_ADS_ACCOUNT: str = "@mychannel" FAKE_ADS_ACCOUNT: str = "@mychannel"
# Revenue withdrawals
FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
FAKE_REVENUE_TX: str = "revenue_tx_abc123"
# Anonymous number # Anonymous number
FAKE_HTML_WITH_CODE: str = """ FAKE_HTML_WITH_CODE: str = """
<table> <table>