From d2d046a2b92af007d6ec98fdb47babfec5f5cfec Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 15 Mar 2026 21:03:03 +0200 Subject: [PATCH 01/14] refactor: restructure as installable PyPI package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename app/ → fragmentapi/ for proper package naming - Add FragmentClient class with gift_premium, gift_stars, topup_ton methods - Restructure core/ → types/ (exceptions, results, constants) - Merge utils/hash.py into utils/client.py - Replace _version.py with importlib.metadata - Add input validation with min/max bounds - Add unit tests: decode, client init/cookies - Rename 002_test_hash → 003_test_hash - Clean up pyproject.toml: pin deps, production classifiers --- .env.example | 11 -- .../unicode_data/15.0.0/charmap.json.gz | Bin 0 -> 21726 bytes app/core/__init__.py | 44 ----- app/core/config.py | 46 ------ app/core/cookies.py | 32 ---- app/core/exceptions.py | 42 ----- app/core/logging.py | 20 --- app/methods/__init__.py | 5 - app/methods/premium.py | 151 ------------------ app/methods/stars.py | 133 --------------- app/methods/ton.py | 132 --------------- app/utils/__init__.py | 14 -- app/utils/client.py | 39 ----- app/utils/decoder.py | 36 ----- app/utils/hash.py | 57 ------- app/utils/wallet.py | 107 ------------- cookies.example.json | 6 - fragmentapi/__init__.py | 44 +++++ fragmentapi/client.py | 112 +++++++++++++ fragmentapi/methods/__init__.py | 5 + fragmentapi/methods/premium.py | 119 ++++++++++++++ fragmentapi/methods/stars.py | 103 ++++++++++++ fragmentapi/methods/ton.py | 108 +++++++++++++ fragmentapi/types/__init__.py | 59 +++++++ {app/core => fragmentapi/types}/constants.py | 5 +- fragmentapi/types/exceptions.py | 106 ++++++++++++ fragmentapi/types/results.py | 34 ++++ fragmentapi/utils/__init__.py | 16 ++ fragmentapi/utils/client.py | 107 +++++++++++++ fragmentapi/utils/decoder.py | 20 +++ fragmentapi/utils/wallet.py | 69 ++++++++ main.py | 66 -------- pyproject.toml | 40 +++++ requirements.txt | 2 - tests/001_test_decode.py | 19 ++- tests/002_test_client.py | 81 ++++++++++ tests/{002_test_hash.py => 003_test_hash.py} | 4 +- tests/conftest.py | 16 +- 38 files changed, 1051 insertions(+), 959 deletions(-) delete mode 100644 .env.example create mode 100644 .hypothesis/unicode_data/15.0.0/charmap.json.gz delete mode 100644 app/core/__init__.py delete mode 100644 app/core/config.py delete mode 100644 app/core/cookies.py delete mode 100644 app/core/exceptions.py delete mode 100644 app/core/logging.py delete mode 100644 app/methods/__init__.py delete mode 100644 app/methods/premium.py delete mode 100644 app/methods/stars.py delete mode 100644 app/methods/ton.py delete mode 100644 app/utils/__init__.py delete mode 100644 app/utils/client.py delete mode 100644 app/utils/decoder.py delete mode 100644 app/utils/hash.py delete mode 100644 app/utils/wallet.py delete mode 100644 cookies.example.json create mode 100644 fragmentapi/__init__.py create mode 100644 fragmentapi/client.py create mode 100644 fragmentapi/methods/__init__.py create mode 100644 fragmentapi/methods/premium.py create mode 100644 fragmentapi/methods/stars.py create mode 100644 fragmentapi/methods/ton.py create mode 100644 fragmentapi/types/__init__.py rename {app/core => fragmentapi/types}/constants.py (90%) create mode 100644 fragmentapi/types/exceptions.py create mode 100644 fragmentapi/types/results.py create mode 100644 fragmentapi/utils/__init__.py create mode 100644 fragmentapi/utils/client.py create mode 100644 fragmentapi/utils/decoder.py create mode 100644 fragmentapi/utils/wallet.py delete mode 100644 main.py create mode 100644 tests/002_test_client.py rename tests/{002_test_hash.py => 003_test_hash.py} (82%) 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/.hypothesis/unicode_data/15.0.0/charmap.json.gz b/.hypothesis/unicode_data/15.0.0/charmap.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..c740c027cc85b3221abcca5382b71ad927e89785 GIT binary patch literal 21726 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 literal 0 HcmV?d00001 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/fragmentapi/__init__.py b/fragmentapi/__init__.py new file mode 100644 index 0000000..14d0e51 --- /dev/null +++ b/fragmentapi/__init__.py @@ -0,0 +1,44 @@ +# Copyright (c) 2025 bohd4nx +# +# This source code is licensed under the MIT License found in the +# LICENSE file in the root directory of this source tree. + +from fragmentapi.client import FragmentClient +from fragmentapi.types import ( + AdsTopupResult, + ClientError, + ConfigError, + CookiesError, + FragmentAPIError, + FragmentError, + HashFetchError, + OperationError, + PremiumResult, + RequestError, + StarsResult, + TransactionError, + UnexpectedError, + UserNotFoundError, + VerificationError, + WalletError, +) + +__all__ = [ + "FragmentClient", + "AdsTopupResult", + "PremiumResult", + "StarsResult", + "ClientError", + "ConfigError", + "CookiesError", + "FragmentAPIError", + "FragmentError", + "HashFetchError", + "OperationError", + "RequestError", + "TransactionError", + "UnexpectedError", + "UserNotFoundError", + "VerificationError", + "WalletError", +] diff --git a/fragmentapi/client.py b/fragmentapi/client.py new file mode 100644 index 0000000..cc93cee --- /dev/null +++ b/fragmentapi/client.py @@ -0,0 +1,112 @@ +import json + +from fragmentapi.methods.premium import gift_premium +from fragmentapi.methods.stars import gift_stars +from fragmentapi.methods.ton import topup_ton +from fragmentapi.types import ( + REQUIRED_COOKIE_KEYS, + SUPPORTED_WALLET_VERSIONS, + AdsTopupResult, + ConfigError, + CookiesError, + PremiumResult, + StarsResult, + WalletVersion, +) + + +class FragmentClient: + """ + Client for the Fragment.com API. + + 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: + ConfigError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid. + CookiesError: If ``cookies`` cannot be parsed or are missing required keys. + + Example:: + + client = FragmentClient( + seed="word1 word2 ...", + api_key="AAABBB...", + cookies={"stel_ssid": "...", "stel_dt": "...", ...}, + ) + result = await client.gift_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 ConfigError(ConfigError.MISSING_VARS.format(keys=", ".join(missing))) + + if isinstance(cookies, str): + try: + cookies = json.loads(cookies) + except Exception as exc: + raise CookiesError(CookiesError.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 CookiesError(CookiesError.MISSING_KEYS.format(keys=", ".join(missing_keys))) + + version = wallet_version.strip().upper() + if version not in SUPPORTED_WALLET_VERSIONS: + raise ConfigError( + ConfigError.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 gift_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult: + """Gift Telegram Premium to a user. + + Args: + username: Recipient's Telegram username (with or without ``@``). + months: Duration — ``3``, ``6``, or ``12``. + show_sender: Show your name as the gift sender. Defaults to ``True``. + + Returns: + :class:`PremiumResult` with ``transaction_id``, ``username``, ``months``, ``timestamp``. + """ + return await gift_premium(self, username, months, show_sender) + + async def gift_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult: + """Gift Telegram Stars to a user. + + Args: + username: Recipient's Telegram username (with or without ``@``). + amount: Number of stars — integer from ``50`` to ``1 000 000``. + show_sender: Show your name as the gift sender. Defaults to ``True``. + + Returns: + :class:`StarsResult` with ``transaction_id``, ``username``, ``stars``, ``timestamp``. + """ + return await gift_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) diff --git a/fragmentapi/methods/__init__.py b/fragmentapi/methods/__init__.py new file mode 100644 index 0000000..b0be4ac --- /dev/null +++ b/fragmentapi/methods/__init__.py @@ -0,0 +1,5 @@ +from fragmentapi.methods.premium import gift_premium +from fragmentapi.methods.stars import gift_stars +from fragmentapi.methods.ton import topup_ton + +__all__ = ["gift_premium", "gift_stars", "topup_ton"] diff --git a/fragmentapi/methods/premium.py b/fragmentapi/methods/premium.py new file mode 100644 index 0000000..b409d63 --- /dev/null +++ b/fragmentapi/methods/premium.py @@ -0,0 +1,119 @@ +import json +import time +from typing import TYPE_CHECKING + +import httpx + +from fragmentapi.types import ( + BASE_HEADERS, + DEVICE, + PREMIUM_PAGE, + ConfigError, + FragmentAPIError, + FragmentError, + PremiumResult, + UnexpectedError, + UserNotFoundError, +) +from fragmentapi.utils import ( + execute_transaction_request, + get_account_info, + get_fragment_hash, + parse_json_response, + process_transaction, +) + +if TYPE_CHECKING: + from fragmentapi.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: + resp = await session.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(UserNotFoundError.NOT_FOUND.format(username=username)) + return recipient + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + recipient: str, + months: int, +) -> str: + await session.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 session.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 FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) + return req_id + + +async def gift_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult: + if months not in (3, 6, 12): + raise ConfigError(ConfigError.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/fragmentapi/methods/stars.py b/fragmentapi/methods/stars.py new file mode 100644 index 0000000..36bd271 --- /dev/null +++ b/fragmentapi/methods/stars.py @@ -0,0 +1,103 @@ +import json +from typing import TYPE_CHECKING + +import httpx + +from fragmentapi.types import ( + BASE_HEADERS, + DEVICE, + STARS_PAGE, + ConfigError, + FragmentAPIError, + FragmentError, + StarsResult, + UnexpectedError, + UserNotFoundError, +) +from fragmentapi.utils import ( + execute_transaction_request, + get_account_info, + get_fragment_hash, + parse_json_response, + process_transaction, +) + +if TYPE_CHECKING: + from fragmentapi.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: + resp = await session.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(UserNotFoundError.NOT_FOUND.format(username=username)) + return recipient + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + recipient: str, + amount: int, +) -> str: + resp = await session.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 FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) + return req_id + + +async def gift_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 ConfigError(ConfigError.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/fragmentapi/methods/ton.py b/fragmentapi/methods/ton.py new file mode 100644 index 0000000..325d8e0 --- /dev/null +++ b/fragmentapi/methods/ton.py @@ -0,0 +1,108 @@ +import json +from typing import TYPE_CHECKING + +import httpx + +from fragmentapi.types import ( + BASE_HEADERS, + DEVICE, + TON_PAGE, + AdsTopupResult, + ConfigError, + FragmentAPIError, + FragmentError, + UnexpectedError, + UserNotFoundError, +) +from fragmentapi.utils import ( + execute_transaction_request, + get_account_info, + get_fragment_hash, + parse_json_response, + process_transaction, +) + +if TYPE_CHECKING: + from fragmentapi.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 session.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, + data={"mode": "new", "method": "updateAdsTopupState"}, + ) + resp = await session.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(UserNotFoundError.NOT_FOUND.format(username=username)) + return recipient + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + recipient: str, + amount: int, +) -> str: + resp = await session.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 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 ConfigError(ConfigError.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/fragmentapi/types/__init__.py b/fragmentapi/types/__init__.py new file mode 100644 index 0000000..addcddd --- /dev/null +++ b/fragmentapi/types/__init__.py @@ -0,0 +1,59 @@ +from fragmentapi.types.constants import ( + BASE_HEADERS, + DEVICE, + PREMIUM_PAGE, + REQUIRED_COOKIE_KEYS, + STARS_PAGE, + SUPPORTED_WALLET_VERSIONS, + TON_PAGE, + WALLET_CLASSES, + WalletVersion, +) +from fragmentapi.types.exceptions import ( + ClientError, + ConfigError, + CookiesError, + FragmentAPIError, + FragmentError, + HashFetchError, + OperationError, + RequestError, + TransactionError, + UnexpectedError, + UserNotFoundError, + VerificationError, + WalletError, +) +from fragmentapi.types.results import AdsTopupResult, PremiumResult, StarsResult + +__all__ = [ + # constants + "BASE_HEADERS", + "DEVICE", + "PREMIUM_PAGE", + "REQUIRED_COOKIE_KEYS", + "STARS_PAGE", + "SUPPORTED_WALLET_VERSIONS", + "TON_PAGE", + "WALLET_CLASSES", + "WalletVersion", + # client exceptions + "ClientError", + "ConfigError", + "CookiesError", + # fragment exceptions + "FragmentAPIError", + "FragmentError", + "HashFetchError", + "OperationError", + "RequestError", + "TransactionError", + "UnexpectedError", + "UserNotFoundError", + "VerificationError", + "WalletError", + # result types + "AdsTopupResult", + "PremiumResult", + "StarsResult", +] diff --git a/app/core/constants.py b/fragmentapi/types/constants.py similarity index 90% rename from app/core/constants.py rename to fragmentapi/types/constants.py index 243edb5..073d1e7 100644 --- a/app/core/constants.py +++ b/fragmentapi/types/constants.py @@ -10,10 +10,13 @@ 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} +# 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/fragmentapi/types/exceptions.py b/fragmentapi/types/exceptions.py new file mode 100644 index 0000000..b19215f --- /dev/null +++ b/fragmentapi/types/exceptions.py @@ -0,0 +1,106 @@ +class FragmentError(Exception): + """Base exception for all fragmentapi library errors.""" + + +class ClientError(FragmentError): + """Raised for client configuration and setup issues (bad params, invalid cookies).""" + + +class ConfigError(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_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 CookiesError(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 HashFetchError(FragmentAPIError): + """Raised when the Fragment API hash cannot be fetched from the page.""" + + 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 RequestError(FragmentAPIError): + """Raised when a Fragment API response 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:.2f} TON. Minimum required is 0.056 TON." + BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}" + ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}" + + +class UnexpectedError(OperationError): + """Raised when an unexpected error occurs during an API call.""" + + UNEXPECTED = "An unexpected error occurred: {exc}" + + +__all__ = [ + "FragmentError", + "ClientError", + "ConfigError", + "CookiesError", + "FragmentAPIError", + "HashFetchError", + "UserNotFoundError", + "TransactionError", + "RequestError", + "VerificationError", + "OperationError", + "WalletError", + "UnexpectedError", +] diff --git a/fragmentapi/types/results.py b/fragmentapi/types/results.py new file mode 100644 index 0000000..234f7cc --- /dev/null +++ b/fragmentapi/types/results.py @@ -0,0 +1,34 @@ +import time +from dataclasses import dataclass, field + +__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult"] + + +@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())) + + +@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())) + + +@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())) diff --git a/fragmentapi/utils/__init__.py b/fragmentapi/utils/__init__.py new file mode 100644 index 0000000..c570db8 --- /dev/null +++ b/fragmentapi/utils/__init__.py @@ -0,0 +1,16 @@ +from fragmentapi.utils.client import ( + execute_transaction_request, + get_fragment_hash, + parse_json_response, +) +from fragmentapi.utils.decoder import clean_decode +from fragmentapi.utils.wallet import get_account_info, process_transaction + +__all__ = [ + "clean_decode", + "execute_transaction_request", + "get_account_info", + "get_fragment_hash", + "parse_json_response", + "process_transaction", +] diff --git a/fragmentapi/utils/client.py b/fragmentapi/utils/client.py new file mode 100644 index 0000000..53be7bc --- /dev/null +++ b/fragmentapi/utils/client.py @@ -0,0 +1,107 @@ +import re +from typing import Any + +import httpx + +from fragmentapi.types import HashFetchError, RequestError, 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: + HashFetchError: 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 HashFetchError(HashFetchError.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 HashFetchError(HashFetchError.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: + RequestError: If the response body cannot be decoded as JSON. + """ + try: + return response.json() + except Exception as exc: + raise RequestError(RequestError.UNPARSEABLE.format(context=context, exc=exc)) from exc + + +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. + RequestError: If the response cannot be parsed. + """ + url = f"https://fragment.com/api?hash={fragment_hash}" + resp = await session.post(url, headers=headers, data=tx_data) + transaction = parse_json_response(resp, tx_data.get("method", "transaction")) + + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) + + return transaction diff --git a/fragmentapi/utils/decoder.py b/fragmentapi/utils/decoder.py new file mode 100644 index 0000000..43a9e75 --- /dev/null +++ b/fragmentapi/utils/decoder.py @@ -0,0 +1,20 @@ +import base64 + +from pytoniq_core import Cell + +from fragmentapi.types import RequestError + + +def clean_decode(payload: str) -> str: + 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 RequestError(RequestError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc diff --git a/fragmentapi/utils/wallet.py b/fragmentapi/utils/wallet.py new file mode 100644 index 0000000..e852bdc --- /dev/null +++ b/fragmentapi/utils/wallet.py @@ -0,0 +1,69 @@ +import base64 +from typing import TYPE_CHECKING, Any + +from tonutils.clients import TonapiClient +from tonutils.types import NetworkGlobalID + +from fragmentapi.types import WALLET_CLASSES, TransactionError, WalletError +from fragmentapi.utils.decoder import clean_decode + +if TYPE_CHECKING: + from fragmentapi.client import FragmentClient + + +def _init_ton_client(client: "FragmentClient") -> TonapiClient: + return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) + + +async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str: + if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: + raise TransactionError(TransactionError.INVALID_PAYLOAD) + + # 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 _init_ton_client(client) as ton: + wallet_cls = WALLET_CLASSES[client.wallet_version] + wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) + + # Check balance before broadcasting + try: + await wallet.refresh() + balance_ton = wallet.balance / 1_000_000_000 + if balance_ton < 0.056: + raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton)) + except WalletError: + raise + except Exception as exc: + raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=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, + ) + return result.normalized_hash + 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]: + async with _init_ton_client(client) 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 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/pyproject.toml b/pyproject.toml index 48cb023..3a407fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,43 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "fragmentapi" +version = "2026.1.0" +description = "Python library for the Fragment.com API — gift 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.0", +] + +[project.urls] +Homepage = "https://github.com/bohd4nx/FragmentAPI" +Repository = "https://github.com/bohd4nx/FragmentAPI" +Issues = "https://github.com/bohd4nx/FragmentAPI/issues" + +[tool.hatch.build.targets.wheel] +packages = ["fragmentapi"] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["[0-9][0-9][0-9]_test_*.py"] diff --git a/requirements.txt b/requirements.txt index ab531a7..5d930d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,2 @@ -python-dotenv==1.2.2 -asyncio==4.0.0 httpx==0.28.1 tonutils[pytoniq]==2.0.0 diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 408de53..d968e5d 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 fragmentapi.types import RequestError +from fragmentapi.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_request_error() -> None: + with pytest.raises(RequestError): + 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..4aee0d0 --- /dev/null +++ b/tests/002_test_client.py @@ -0,0 +1,81 @@ +"""Unit tests for FragmentClient — init validation and cookie parsing (no network calls).""" + +import json + +import pytest + +from fragmentapi import FragmentClient +from fragmentapi.types import ConfigError, CookiesError + +VALID_SEED = "abandon " * 23 + "about" +VALID_API_KEY = "test_api_key" +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(ConfigError): + FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES) + + +def test_whitespace_only_seed_raises() -> None: + with pytest.raises(ConfigError): + FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES) + + +def test_missing_api_key_raises() -> None: + with pytest.raises(ConfigError): + FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES) + + +def test_unsupported_wallet_version_raises() -> None: + with pytest.raises(ConfigError): + 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(CookiesError): + FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies="{not valid json}") + + +def test_missing_cookie_key_raises() -> None: + with pytest.raises(CookiesError): + 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(CookiesError): + 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(CookiesError): + FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad) 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..0140f09 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 fragmentapi.types import BASE_HEADERS, STARS_PAGE +from fragmentapi.utils import get_fragment_hash @pytest.mark.asyncio 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}") From 9a090e3dbc35d36a9729c1d8da7501c5392fd7d7 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 15 Mar 2026 21:45:24 +0200 Subject: [PATCH 02/14] feat: implement CI/CD workflows for testing, linting, and publishing to PyPI --- .github/ISSUE_TEMPLATE/bug.yaml | 101 ++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature.yaml | 51 +++++++++ .github/PULL_REQUEST_TEMPLATE.md | 34 ++++++ .github/workflows/lint.yml | 31 ++++++ .github/workflows/publish.yml | 49 +++++++++ .github/workflows/tests.yml | 11 +- .gitignore | 13 +++ .../unicode_data/15.0.0/charmap.json.gz | Bin 21726 -> 0 bytes fragmentapi/methods/premium.py | 31 +++--- fragmentapi/methods/stars.py | 26 +++-- fragmentapi/methods/ton.py | 31 +++--- fragmentapi/types/__init__.py | 2 + fragmentapi/types/constants.py | 3 + fragmentapi/utils/__init__.py | 6 +- fragmentapi/utils/decoder.py | 15 +++ fragmentapi/utils/{client.py => http.py} | 33 +++++- fragmentapi/utils/wallet.py | 35 +++++- pyproject.toml | 14 ++- requirements-dev.txt | 1 + requirements.txt | 3 +- 21 files changed, 438 insertions(+), 57 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.yaml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yaml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/publish.yml delete mode 100644 .hypothesis/unicode_data/15.0.0/charmap.json.gz rename fragmentapi/utils/{client.py => http.py} (76%) create mode 100644 requirements-dev.txt diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000..230a733 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,101 @@ +name: Bug report +description: Report an issue or unexpected behavior in FragmentAPI. +labels: + - bug +body: + - type: checkboxes + attributes: + label: Checklist + options: + - label: I am sure the error is coming from FragmentAPI 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: FragmentAPI version + description: Run `pip show fragmentapi` 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 gifted and StarsResult returned. + validations: + required: true + + - type: textarea + attributes: + label: Current behavior + description: Describe what is actually happening. + placeholder: e.g. RequestError 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 gift_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 fragmentapi import FragmentClient + + async def main(): + client = FragmentClient(...) + result = await client.gift_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 + ... + fragmentapi.types.RequestError: ... + 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..7fc7197 --- /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/FragmentAPI/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..ae3d319 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -0,0 +1,51 @@ +name: Feature request +description: Suggest an improvement or new feature for FragmentAPI. +labels: + - enhancement +body: + - type: dropdown + attributes: + label: FragmentAPI 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..631b583 --- /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: +* FragmentAPI 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/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ed1ead3 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,31 @@ +name: Lint + +on: + push: + branches: [master] + pull_request: + branches: [master] + +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" + + - name: Install uv + uses: astral-sh/setup-uv@v7.5.0 + + - name: Install dev dependencies + run: uv pip install --system ".[dev]" + + - name: Run ruff + run: ruff check . + + - name: Run black + run: black --check . --target-version py312 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..bc243b9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,49 @@ +name: Publish to PyPI + +on: + push: + branches: [master] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6.0.2 + + - uses: actions/setup-python@v6.2.0 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v7.5.0 + + - name: Build distribution + run: uv build + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/* + + publish: + name: Publish + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/fragmentapi/ + permissions: + id-token: write + + steps: + - name: Download artifacts + uses: actions/download-artifact@v8.0.1 + with: + name: dist + path: dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@v1.13.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d698a55..4edd34f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,18 +8,21 @@ on: jobs: test: + name: Run tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.0.2 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v6.2.0 with: python-version: "3.12" - cache: "pip" + + - name: Install uv + uses: astral-sh/setup-uv@v7.5.0 - name: Install dependencies - run: pip install -r requirements.txt pytest pytest-asyncio + run: uv pip install --system ".[dev]" - name: Write cookies.json if: ${{ env.COOKIES_JSON != '' }} diff --git a/.gitignore b/.gitignore index 9cb8f25..082dca9 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,16 @@ logs/ .DS_Store Thumbs.db cookies.json + +# Testing & tooling artifacts +.hypothesis/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Build & distribution +dist/ +build/ +*.egg-info/ diff --git a/.hypothesis/unicode_data/15.0.0/charmap.json.gz b/.hypothesis/unicode_data/15.0.0/charmap.json.gz deleted file mode 100644 index c740c027cc85b3221abcca5382b71ad927e89785..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21726 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 diff --git a/fragmentapi/methods/premium.py b/fragmentapi/methods/premium.py index b409d63..4f5cce6 100644 --- a/fragmentapi/methods/premium.py +++ b/fragmentapi/methods/premium.py @@ -17,9 +17,9 @@ from fragmentapi.types import ( ) from fragmentapi.utils import ( execute_transaction_request, + fragment_post, get_account_info, get_fragment_hash, - parse_json_response, process_transaction, ) @@ -40,16 +40,16 @@ async def _search_recipient( username: str, months: int, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "query": username, "months": months, "method": "searchPremiumGiftRecipient", }, ) - result = parse_json_response(resp, "searchPremiumGiftRecipient") recipient = result.get("found", {}).get("recipient") if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) @@ -62,26 +62,27 @@ async def _init_request( recipient: str, months: int, ) -> str: - await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + await fragment_post( + session, + fragment_hash, + HEADERS, + { "mode": "new", "lv": "false", "dh": str(int(time.time())), "method": "updatePremiumState", }, ) - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "recipient": recipient, "months": months, "method": "initGiftPremiumRequest", }, ) - result = parse_json_response(resp, "initGiftPremiumRequest") req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) diff --git a/fragmentapi/methods/stars.py b/fragmentapi/methods/stars.py index 36bd271..9f15853 100644 --- a/fragmentapi/methods/stars.py +++ b/fragmentapi/methods/stars.py @@ -16,9 +16,9 @@ from fragmentapi.types import ( ) from fragmentapi.utils import ( execute_transaction_request, + fragment_post, get_account_info, get_fragment_hash, - parse_json_response, process_transaction, ) @@ -38,12 +38,16 @@ async def _search_recipient( fragment_hash: str, username: str, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={"query": username, "quantity": "", "method": "searchStarsRecipient"}, + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { + "query": username, + "quantity": "", + "method": "searchStarsRecipient", + }, ) - result = parse_json_response(resp, "searchStarsRecipient") recipient = result.get("found", {}).get("recipient") if not recipient: raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) @@ -56,16 +60,16 @@ async def _init_request( recipient: str, amount: int, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "recipient": recipient, "quantity": amount, "method": "initBuyStarsRequest", }, ) - result = parse_json_response(resp, "initBuyStarsRequest") req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) diff --git a/fragmentapi/methods/ton.py b/fragmentapi/methods/ton.py index 325d8e0..e4556cc 100644 --- a/fragmentapi/methods/ton.py +++ b/fragmentapi/methods/ton.py @@ -16,9 +16,9 @@ from fragmentapi.types import ( ) from fragmentapi.utils import ( execute_transaction_request, + fragment_post, get_account_info, get_fragment_hash, - parse_json_response, process_transaction, ) @@ -38,17 +38,16 @@ async def _search_recipient( fragment_hash: str, username: str, ) -> str: - await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={"mode": "new", "method": "updateAdsTopupState"}, + await fragment_post(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"}) + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { + "query": username, + "method": "searchAdsTopupRecipient", + }, ) - resp = await session.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(UserNotFoundError.NOT_FOUND.format(username=username)) @@ -61,16 +60,16 @@ async def _init_request( recipient: str, amount: int, ) -> str: - resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", - headers=HEADERS, - data={ + result = await fragment_post( + session, + fragment_hash, + HEADERS, + { "recipient": recipient, "amount": amount, "method": "initAdsTopupRequest", }, ) - result = parse_json_response(resp, "initAdsTopupRequest") req_id = result.get("req_id") if not req_id: raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) diff --git a/fragmentapi/types/__init__.py b/fragmentapi/types/__init__.py index addcddd..13b69d4 100644 --- a/fragmentapi/types/__init__.py +++ b/fragmentapi/types/__init__.py @@ -1,6 +1,7 @@ from fragmentapi.types.constants import ( BASE_HEADERS, DEVICE, + MIN_TON_BALANCE, PREMIUM_PAGE, REQUIRED_COOKIE_KEYS, STARS_PAGE, @@ -30,6 +31,7 @@ __all__ = [ # constants "BASE_HEADERS", "DEVICE", + "MIN_TON_BALANCE", "PREMIUM_PAGE", "REQUIRED_COOKIE_KEYS", "STARS_PAGE", diff --git a/fragmentapi/types/constants.py b/fragmentapi/types/constants.py index 073d1e7..2bb799d 100644 --- a/fragmentapi/types/constants.py +++ b/fragmentapi/types/constants.py @@ -10,6 +10,9 @@ 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") diff --git a/fragmentapi/utils/__init__.py b/fragmentapi/utils/__init__.py index c570db8..a888ac8 100644 --- a/fragmentapi/utils/__init__.py +++ b/fragmentapi/utils/__init__.py @@ -1,14 +1,16 @@ -from fragmentapi.utils.client import ( +from fragmentapi.utils.decoder import clean_decode +from fragmentapi.utils.http import ( execute_transaction_request, + fragment_post, get_fragment_hash, parse_json_response, ) -from fragmentapi.utils.decoder import clean_decode from fragmentapi.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", diff --git a/fragmentapi/utils/decoder.py b/fragmentapi/utils/decoder.py index 43a9e75..77e076a 100644 --- a/fragmentapi/utils/decoder.py +++ b/fragmentapi/utils/decoder.py @@ -6,6 +6,21 @@ from fragmentapi.types import RequestError 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: + RequestError: If the payload cannot be decoded or parsed. + """ s = payload.strip() if not s: return "" diff --git a/fragmentapi/utils/client.py b/fragmentapi/utils/http.py similarity index 76% rename from fragmentapi/utils/client.py rename to fragmentapi/utils/http.py index 53be7bc..0874645 100644 --- a/fragmentapi/utils/client.py +++ b/fragmentapi/utils/http.py @@ -76,6 +76,35 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any raise RequestError(RequestError.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, @@ -97,9 +126,7 @@ async def execute_transaction_request( VerificationError: If Fragment requires KYC verification. RequestError: If the response cannot be parsed. """ - url = f"https://fragment.com/api?hash={fragment_hash}" - resp = await session.post(url, headers=headers, data=tx_data) - transaction = parse_json_response(resp, tx_data.get("method", "transaction")) + transaction = await fragment_post(session, fragment_hash, headers, tx_data) if transaction.get("need_verify"): raise VerificationError(VerificationError.KYC_REQUIRED) diff --git a/fragmentapi/utils/wallet.py b/fragmentapi/utils/wallet.py index e852bdc..01a0939 100644 --- a/fragmentapi/utils/wallet.py +++ b/fragmentapi/utils/wallet.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any from tonutils.clients import TonapiClient from tonutils.types import NetworkGlobalID -from fragmentapi.types import WALLET_CLASSES, TransactionError, WalletError +from fragmentapi.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError from fragmentapi.utils.decoder import clean_decode if TYPE_CHECKING: @@ -16,6 +16,22 @@ def _init_ton_client(client: "FragmentClient") -> TonapiClient: 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) @@ -30,7 +46,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) try: await wallet.refresh() balance_ton = wallet.balance / 1_000_000_000 - if balance_ton < 0.056: + if balance_ton < MIN_TON_BALANCE: raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton)) except WalletError: raise @@ -54,6 +70,21 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) 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 _init_ton_client(client) as ton: try: wallet_cls = WALLET_CLASSES[client.wallet_version] diff --git a/pyproject.toml b/pyproject.toml index 3a407fb..d62ec9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,14 @@ dependencies = [ "tonutils[pytoniq]==2.0.0", ] +[project.optional-dependencies] +dev = [ + "pytest>=9.0", + "pytest-asyncio>=1.0", + "ruff", + "black", +] + [project.urls] Homepage = "https://github.com/bohd4nx/FragmentAPI" Repository = "https://github.com/bohd4nx/FragmentAPI" @@ -49,11 +57,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 5d930d9..d6e1198 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ -httpx==0.28.1 -tonutils[pytoniq]==2.0.0 +-e . From 7f269d9a87579c0b2e0dd33bb35ad35e75520bc4 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 15 Mar 2026 22:03:32 +0200 Subject: [PATCH 03/14] refactor: rename error classes for consistency and clarity; update related code and tests --- .github/ISSUE_TEMPLATE/bug.yaml | 4 +- README.md | 227 +++++++++++++++----------------- examples/client_init.py | 36 +++++ examples/gift_premium.py | 45 +++++++ examples/gift_stars.py | 45 +++++++ examples/topup_ton.py | 48 +++++++ fragmentapi/__init__.py | 16 +-- fragmentapi/client.py | 20 +-- fragmentapi/methods/premium.py | 4 +- fragmentapi/methods/stars.py | 4 +- fragmentapi/methods/ton.py | 4 +- fragmentapi/types/__init__.py | 16 +-- fragmentapi/types/exceptions.py | 20 +-- fragmentapi/utils/decoder.py | 6 +- fragmentapi/utils/http.py | 14 +- tests/001_test_decode.py | 6 +- tests/002_test_client.py | 18 +-- 17 files changed, 347 insertions(+), 186 deletions(-) create mode 100644 examples/client_init.py create mode 100644 examples/gift_premium.py create mode 100644 examples/gift_stars.py create mode 100644 examples/topup_ton.py diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml index 230a733..1c685b9 100644 --- a/.github/ISSUE_TEMPLATE/bug.yaml +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -53,7 +53,7 @@ body: attributes: label: Current behavior description: Describe what is actually happening. - placeholder: e.g. RequestError is raised with status 400. + placeholder: e.g. ParseError is raised with status 400. validations: required: true @@ -91,7 +91,7 @@ body: Traceback (most recent call last): File "main.py", line 7, in main ... - fragmentapi.types.RequestError: ... + fragmentapi.types.ParseError: ... render: sh - type: textarea diff --git a/README.md b/README.md index 280f9a1..a878bae 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@

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 — gift 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) +[![PyPI version](https://img.shields.io/pypi/v/fragmentapi?style=flat&color=blue)](https://pypi.org/project/fragmentapi/) +[![PyPI downloads](https://img.shields.io/pypi/dm/fragmentapi?style=flat&color=brightgreen)](https://pypi.org/project/fragmentapi/) +[![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/FragmentAPI?style=flat&color=lightgrey)](LICENSE) [![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) -[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/FragmentAPI/issues) · [Request Feature](https://github.com/bohd4nx/FragmentAPI/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)
@@ -21,51 +22,94 @@ ## ✨ 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 Gifts** — Gift Premium to any user (3, 6, or 12 months) +- ⭐ **Telegram Stars Purchases** — Gift Stars to 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 fragmentapi +``` + +Requires **Python 3.12+**. + +--- ## 🚀 Quick Start -### 1. Installation +```python +import asyncio +from fragmentapi import FragmentClient -```bash -git clone https://github.com/bohd4nx/FragmentAPI.git -cd FragmentAPI -pip install -r requirements.txt +client = FragmentClient( + seed="word1 word2 ... word24", + api_key="YOUR_TONAPI_KEY", + cookies={ + "stel_ssid": "...", + "stel_dt": "...", + "stel_token": "...", + "stel_ton_token": "...", + }, +) + +async def main(): + # Gift 6 months of Telegram Premium + result = await client.gift_premium("@username", months=6) + print(result.transaction_id) + + # Gift 500 Stars + result = await client.gift_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 | +| -------------------------------------------------- | ---------------- | ---------------------------------- | ------------------------- | +| `gift_premium(username, months, show_sender=True)` | `PremiumResult` | Gift Telegram Premium subscription | `months`: 3, 6, or 12 | +| `gift_stars(username, amount, show_sender=True)` | `StarsResult` | Gift 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 | -**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** → **JSON** +4. Extract these four fields: ```json { @@ -76,107 +120,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 [`fragmentapi/types/exceptions.py`](fragmentapi/types/exceptions.py) for the full list. ```python -import asyncio -from app.methods import topup_ton, buy_premium, buy_stars +from fragmentapi 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.gift_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/examples/client_init.py b/examples/client_init.py new file mode 100644 index 0000000..e81ed8d --- /dev/null +++ b/examples/client_init.py @@ -0,0 +1,36 @@ +""" +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 fragmentapi 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" + ) + + print("FragmentClient initialized") + print(" %-16s %s" % ("Wallet version:", client.wallet_version)) + print(" %-16s %s..." % ("API key:", client.api_key[:8])) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/gift_premium.py b/examples/gift_premium.py new file mode 100644 index 0000000..661843d --- /dev/null +++ b/examples/gift_premium.py @@ -0,0 +1,45 @@ +""" +Example: gift Telegram Premium to a user. + +Supported durations: 3, 6, or 12 months. +Set show_sender=False to send anonymously. +""" + +import asyncio + +from fragmentapi 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.gift_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 gifted") + 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/gift_stars.py b/examples/gift_stars.py new file mode 100644 index 0000000..3f5ef7f --- /dev/null +++ b/examples/gift_stars.py @@ -0,0 +1,45 @@ +""" +Example: gift Telegram Stars to a user. + +Amount must be an integer between 50 and 1 000 000. +Set show_sender=False to send anonymously. +""" + +import asyncio + +from fragmentapi 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.gift_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 gifted") + 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..abf2abf --- /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 fragmentapi 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/fragmentapi/__init__.py b/fragmentapi/__init__.py index 14d0e51..367e191 100644 --- a/fragmentapi/__init__.py +++ b/fragmentapi/__init__.py @@ -7,14 +7,14 @@ from fragmentapi.client import FragmentClient from fragmentapi.types import ( AdsTopupResult, ClientError, - ConfigError, - CookiesError, + ConfigurationError, + CookieError, FragmentAPIError, FragmentError, - HashFetchError, + FragmentPageError, OperationError, + ParseError, PremiumResult, - RequestError, StarsResult, TransactionError, UnexpectedError, @@ -29,13 +29,13 @@ __all__ = [ "PremiumResult", "StarsResult", "ClientError", - "ConfigError", - "CookiesError", + "ConfigurationError", + "CookieError", "FragmentAPIError", "FragmentError", - "HashFetchError", + "FragmentPageError", "OperationError", - "RequestError", + "ParseError", "TransactionError", "UnexpectedError", "UserNotFoundError", diff --git a/fragmentapi/client.py b/fragmentapi/client.py index cc93cee..d402bb4 100644 --- a/fragmentapi/client.py +++ b/fragmentapi/client.py @@ -7,8 +7,8 @@ from fragmentapi.types import ( REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, AdsTopupResult, - ConfigError, - CookiesError, + ConfigurationError, + CookieError, PremiumResult, StarsResult, WalletVersion, @@ -26,8 +26,8 @@ class FragmentClient: wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). Raises: - ConfigError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid. - CookiesError: If ``cookies`` cannot be parsed or are missing required keys. + ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid. + CookieError: If ``cookies`` cannot be parsed or are missing required keys. Example:: @@ -49,22 +49,24 @@ class FragmentClient: ) -> None: missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()] if missing: - raise ConfigError(ConfigError.MISSING_VARS.format(keys=", ".join(missing))) + raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing))) if isinstance(cookies, str): try: cookies = json.loads(cookies) except Exception as exc: - raise CookiesError(CookiesError.READ_FAILED.format(exc=exc)) from 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 CookiesError(CookiesError.MISSING_KEYS.format(keys=", ".join(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 ConfigError( - ConfigError.UNSUPPORTED_VERSION.format(version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS))) + raise ConfigurationError( + ConfigurationError.UNSUPPORTED_VERSION.format( + version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS)) + ) ) self.seed: str = seed.strip() diff --git a/fragmentapi/methods/premium.py b/fragmentapi/methods/premium.py index 4f5cce6..958b6e7 100644 --- a/fragmentapi/methods/premium.py +++ b/fragmentapi/methods/premium.py @@ -8,7 +8,7 @@ from fragmentapi.types import ( BASE_HEADERS, DEVICE, PREMIUM_PAGE, - ConfigError, + ConfigurationError, FragmentAPIError, FragmentError, PremiumResult, @@ -91,7 +91,7 @@ async def _init_request( async def gift_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult: if months not in (3, 6, 12): - raise ConfigError(ConfigError.INVALID_MONTHS) + raise ConfigurationError(ConfigurationError.INVALID_MONTHS) try: fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE) diff --git a/fragmentapi/methods/stars.py b/fragmentapi/methods/stars.py index 9f15853..d5b4b50 100644 --- a/fragmentapi/methods/stars.py +++ b/fragmentapi/methods/stars.py @@ -7,7 +7,7 @@ from fragmentapi.types import ( BASE_HEADERS, DEVICE, STARS_PAGE, - ConfigError, + ConfigurationError, FragmentAPIError, FragmentError, StarsResult, @@ -78,7 +78,7 @@ async def _init_request( async def gift_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 ConfigError(ConfigError.INVALID_STARS_AMOUNT) + raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) try: fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE) diff --git a/fragmentapi/methods/ton.py b/fragmentapi/methods/ton.py index e4556cc..6cd7ebc 100644 --- a/fragmentapi/methods/ton.py +++ b/fragmentapi/methods/ton.py @@ -8,7 +8,7 @@ from fragmentapi.types import ( DEVICE, TON_PAGE, AdsTopupResult, - ConfigError, + ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, @@ -78,7 +78,7 @@ async def _init_request( 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 ConfigError(ConfigError.INVALID_TON_AMOUNT) + raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE) diff --git a/fragmentapi/types/__init__.py b/fragmentapi/types/__init__.py index 13b69d4..a717ccc 100644 --- a/fragmentapi/types/__init__.py +++ b/fragmentapi/types/__init__.py @@ -12,13 +12,13 @@ from fragmentapi.types.constants import ( ) from fragmentapi.types.exceptions import ( ClientError, - ConfigError, - CookiesError, + ConfigurationError, + CookieError, FragmentAPIError, FragmentError, - HashFetchError, + FragmentPageError, OperationError, - RequestError, + ParseError, TransactionError, UnexpectedError, UserNotFoundError, @@ -41,14 +41,14 @@ __all__ = [ "WalletVersion", # client exceptions "ClientError", - "ConfigError", - "CookiesError", + "ConfigurationError", + "CookieError", # fragment exceptions "FragmentAPIError", "FragmentError", - "HashFetchError", + "FragmentPageError", "OperationError", - "RequestError", + "ParseError", "TransactionError", "UnexpectedError", "UserNotFoundError", diff --git a/fragmentapi/types/exceptions.py b/fragmentapi/types/exceptions.py index b19215f..e1c800d 100644 --- a/fragmentapi/types/exceptions.py +++ b/fragmentapi/types/exceptions.py @@ -6,7 +6,7 @@ class ClientError(FragmentError): """Raised for client configuration and setup issues (bad params, invalid cookies).""" -class ConfigError(ClientError): +class ConfigurationError(ClientError): """Raised when required client parameters are missing or invalid.""" MISSING_VARS = "Missing required parameter(s): {keys}." @@ -16,7 +16,7 @@ class ConfigError(ClientError): INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON." -class CookiesError(ClientError): +class CookieError(ClientError): """Raised when cookies are unreadable or missing required fields.""" READ_FAILED = "Failed to parse cookies: {exc}" @@ -33,8 +33,8 @@ class FragmentAPIError(FragmentError): ) -class HashFetchError(FragmentAPIError): - """Raised when the Fragment API hash cannot be fetched from the page.""" +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 = ( @@ -59,8 +59,8 @@ class TransactionError(FragmentAPIError): BROADCAST_FAILED = "Transaction broadcast failed: {exc}" -class RequestError(FragmentAPIError): - """Raised when a Fragment API response cannot be parsed.""" +class ParseError(FragmentAPIError): + """Raised when a Fragment API response or payload cannot be parsed.""" UNPARSEABLE = "Fragment API returned an unparseable response for '{context}': {exc}" @@ -92,13 +92,13 @@ class UnexpectedError(OperationError): __all__ = [ "FragmentError", "ClientError", - "ConfigError", - "CookiesError", + "ConfigurationError", + "CookieError", "FragmentAPIError", - "HashFetchError", + "FragmentPageError", "UserNotFoundError", "TransactionError", - "RequestError", + "ParseError", "VerificationError", "OperationError", "WalletError", diff --git a/fragmentapi/utils/decoder.py b/fragmentapi/utils/decoder.py index 77e076a..b7130ee 100644 --- a/fragmentapi/utils/decoder.py +++ b/fragmentapi/utils/decoder.py @@ -2,7 +2,7 @@ import base64 from pytoniq_core import Cell -from fragmentapi.types import RequestError +from fragmentapi.types import ParseError def clean_decode(payload: str) -> str: @@ -19,7 +19,7 @@ def clean_decode(payload: str) -> str: Decoded comment string, or ``""`` for an empty payload. Raises: - RequestError: If the payload cannot be decoded or parsed. + ParseError: If the payload cannot be decoded or parsed. """ s = payload.strip() if not s: @@ -32,4 +32,4 @@ def clean_decode(payload: str) -> str: sl.load_uint(32) # op code — always 0 for text comment return sl.load_snake_string().strip() except Exception as exc: - raise RequestError(RequestError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc + raise ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc diff --git a/fragmentapi/utils/http.py b/fragmentapi/utils/http.py index 0874645..07087d4 100644 --- a/fragmentapi/utils/http.py +++ b/fragmentapi/utils/http.py @@ -3,7 +3,7 @@ from typing import Any import httpx -from fragmentapi.types import HashFetchError, RequestError, VerificationError +from fragmentapi.types import FragmentPageError, ParseError, VerificationError async def get_fragment_hash( @@ -26,7 +26,7 @@ async def get_fragment_hash( Lowercase hex hash string. Raises: - HashFetchError: If the page returns a non-200 status or the hash + FragmentPageError: If the page returns a non-200 status or the hash is not found in the response HTML. """ page_headers = { @@ -48,11 +48,11 @@ async def get_fragment_hash( response = await session.get(page_url, headers=page_headers) if response.status_code != 200: - raise HashFetchError(HashFetchError.BAD_STATUS.format(status=response.status_code, url=page_url)) + 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 HashFetchError(HashFetchError.NOT_FOUND.format(url=page_url)) + raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url)) return match.group(1) @@ -68,12 +68,12 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any Parsed response as a dict. Raises: - RequestError: If the response body cannot be decoded as JSON. + ParseError: If the response body cannot be decoded as JSON. """ try: return response.json() except Exception as exc: - raise RequestError(RequestError.UNPARSEABLE.format(context=context, exc=exc)) from exc + raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc async def fragment_post( @@ -124,7 +124,7 @@ async def execute_transaction_request( Raises: VerificationError: If Fragment requires KYC verification. - RequestError: If the response cannot be parsed. + ParseError: If the response cannot be parsed. """ transaction = await fragment_post(session, fragment_hash, headers, tx_data) diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index d968e5d..7ccbe89 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -4,7 +4,7 @@ import re import pytest -from fragmentapi.types import RequestError +from fragmentapi.types import ParseError from fragmentapi.utils.decoder import clean_decode PAYLOADS = [ @@ -35,6 +35,6 @@ def test_empty_payload_returns_empty_string() -> None: assert clean_decode("") == "" -def test_invalid_payload_raises_request_error() -> None: - with pytest.raises(RequestError): +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 index 4aee0d0..ea6e42c 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -5,7 +5,7 @@ import json import pytest from fragmentapi import FragmentClient -from fragmentapi.types import ConfigError, CookiesError +from fragmentapi.types import ConfigurationError, CookieError VALID_SEED = "abandon " * 23 + "about" VALID_API_KEY = "test_api_key" @@ -35,22 +35,22 @@ def test_wallet_version_is_case_insensitive() -> None: def test_missing_seed_raises() -> None: - with pytest.raises(ConfigError): + with pytest.raises(ConfigurationError): FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES) def test_whitespace_only_seed_raises() -> None: - with pytest.raises(ConfigError): + with pytest.raises(ConfigurationError): FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES) def test_missing_api_key_raises() -> None: - with pytest.raises(ConfigError): + with pytest.raises(ConfigurationError): FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES) def test_unsupported_wallet_version_raises() -> None: - with pytest.raises(ConfigError): + with pytest.raises(ConfigurationError): FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2") @@ -60,22 +60,22 @@ def test_cookies_as_json_string() -> None: def test_invalid_cookies_json_raises() -> None: - with pytest.raises(CookiesError): + 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(CookiesError): + 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(CookiesError): + 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(CookiesError): + with pytest.raises(CookieError): FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad) From 8135a9da7e6488056c9036b19d423224099de17f Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 15 Mar 2026 22:07:01 +0200 Subject: [PATCH 04/14] Refactor FragmentAPI to pyfragment - Renamed the package from `fragmentapi` to `pyfragment` across all modules and tests. - Removed the old wallet utility functions and replaced them with new implementations. - Updated the `pyproject.toml` to reflect the new package name and repository links. - Adjusted all import statements in tests to use the new package name. - Implemented new methods for gifting Telegram Premium, Stars, and topping up TON balance. - Added exception handling for various error scenarios in the API interactions. - Created new utility functions for handling HTTP requests and decoding payloads. - Established a clear structure for types and constants used throughout the library. --- .github/ISSUE_TEMPLATE/bug.yaml | 12 +++++------ .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/ISSUE_TEMPLATE/feature.yaml | 4 ++-- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/workflows/publish.yml | 2 +- README.md | 20 +++++++++---------- examples/client_init.py | 2 +- examples/gift_premium.py | 2 +- examples/gift_stars.py | 2 +- examples/topup_ton.py | 2 +- fragmentapi/methods/__init__.py | 5 ----- {fragmentapi => pyfragment}/__init__.py | 4 ++-- {fragmentapi => pyfragment}/client.py | 8 ++++---- pyfragment/methods/__init__.py | 5 +++++ .../methods/premium.py | 6 +++--- {fragmentapi => pyfragment}/methods/stars.py | 6 +++--- {fragmentapi => pyfragment}/methods/ton.py | 6 +++--- {fragmentapi => pyfragment}/types/__init__.py | 6 +++--- .../types/constants.py | 0 .../types/exceptions.py | 2 +- {fragmentapi => pyfragment}/types/results.py | 0 {fragmentapi => pyfragment}/utils/__init__.py | 6 +++--- {fragmentapi => pyfragment}/utils/decoder.py | 2 +- {fragmentapi => pyfragment}/utils/http.py | 2 +- {fragmentapi => pyfragment}/utils/wallet.py | 6 +++--- pyproject.toml | 10 +++++----- tests/001_test_decode.py | 4 ++-- tests/002_test_client.py | 4 ++-- tests/003_test_hash.py | 4 ++-- 29 files changed, 68 insertions(+), 68 deletions(-) delete mode 100644 fragmentapi/methods/__init__.py rename {fragmentapi => pyfragment}/__init__.py (91%) rename {fragmentapi => pyfragment}/client.py (95%) create mode 100644 pyfragment/methods/__init__.py rename {fragmentapi => pyfragment}/methods/premium.py (96%) rename {fragmentapi => pyfragment}/methods/stars.py (96%) rename {fragmentapi => pyfragment}/methods/ton.py (96%) rename {fragmentapi => pyfragment}/types/__init__.py (87%) rename {fragmentapi => pyfragment}/types/constants.py (100%) rename {fragmentapi => pyfragment}/types/exceptions.py (98%) rename {fragmentapi => pyfragment}/types/results.py (100%) rename {fragmentapi => pyfragment}/utils/__init__.py (64%) rename {fragmentapi => pyfragment}/utils/decoder.py (96%) rename {fragmentapi => pyfragment}/utils/http.py (98%) rename {fragmentapi => pyfragment}/utils/wallet.py (95%) diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml index 1c685b9..afc89fd 100644 --- a/.github/ISSUE_TEMPLATE/bug.yaml +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -1,5 +1,5 @@ name: Bug report -description: Report an issue or unexpected behavior in FragmentAPI. +description: Report an issue or unexpected behavior in pyfragment. labels: - bug body: @@ -7,7 +7,7 @@ body: attributes: label: Checklist options: - - label: I am sure the error is coming from FragmentAPI code + - 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 @@ -35,8 +35,8 @@ body: - type: input attributes: - label: FragmentAPI version - description: Run `pip show fragmentapi` inside your virtualenv + label: pyfragment version + description: Run `pip show pyfragment` inside your virtualenv placeholder: e.g. 2026.1.0 validations: required: true @@ -74,7 +74,7 @@ body: description: Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) if applicable. placeholder: | import asyncio - from fragmentapi import FragmentClient + from pyfragment import FragmentClient async def main(): client = FragmentClient(...) @@ -91,7 +91,7 @@ body: Traceback (most recent call last): File "main.py", line 7, in main ... - fragmentapi.types.ParseError: ... + pyfragment.types.ParseError: ... render: sh - type: textarea diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7fc7197..ea24733 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Ask a question or start a discussion - url: https://github.com/bohd4nx/FragmentAPI/discussions + 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 index ae3d319..e2a26c4 100644 --- a/.github/ISSUE_TEMPLATE/feature.yaml +++ b/.github/ISSUE_TEMPLATE/feature.yaml @@ -1,11 +1,11 @@ name: Feature request -description: Suggest an improvement or new feature for FragmentAPI. +description: Suggest an improvement or new feature for pyfragment. labels: - enhancement body: - type: dropdown attributes: - label: FragmentAPI version + label: pyfragment version description: Which version are you running? options: - latest diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 631b583..e00c570 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -23,7 +23,7 @@ Describe the tests you ran to verify the change and list any relevant details. **Test configuration:** * OS: * Python version: -* FragmentAPI version: +* pyfragment version: ## Checklist diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bc243b9..d8955a1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/project/fragmentapi/ + url: https://pypi.org/project/pyfragment/ permissions: id-token: write diff --git a/README.md b/README.md index a878bae..2e920ed 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,14 @@ Python library for the Fragment.com API — gift Telegram Stars, Premium, and top up TON Ads balance.

-[![PyPI version](https://img.shields.io/pypi/v/fragmentapi?style=flat&color=blue)](https://pypi.org/project/fragmentapi/) -[![PyPI downloads](https://img.shields.io/pypi/dm/fragmentapi?style=flat&color=brightgreen)](https://pypi.org/project/fragmentapi/) +[![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/FragmentAPI?style=flat&color=lightgrey)](LICENSE) -[![Stars](https://img.shields.io/github/stars/bohd4nx/FragmentAPI?style=flat&color=yellow)](https://github.com/bohd4nx/FragmentAPI/stargazers) -[![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) +[![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/tests.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)
@@ -33,7 +33,7 @@ ## 📦 Installation ```bash -pip install fragmentapi +pip install pyfragment ``` Requires **Python 3.12+**. @@ -44,7 +44,7 @@ Requires **Python 3.12+**. ```python import asyncio -from fragmentapi import FragmentClient +from pyfragment import FragmentClient client = FragmentClient( seed="word1 word2 ... word24", @@ -146,10 +146,10 @@ Go to **Settings → Backup** → copy the 24 words. ## 🗂️ Error Handling -All exceptions inherit from `FragmentError` — see [`fragmentapi/types/exceptions.py`](fragmentapi/types/exceptions.py) for the full list. +All exceptions inherit from `FragmentError` — see [`pyfragment/types/exceptions.py`](pyfragment/types/exceptions.py) for the full list. ```python -from fragmentapi import FragmentClient, UserNotFoundError, ConfigurationError, WalletError +from pyfragment import FragmentClient, UserNotFoundError, ConfigurationError, WalletError try: result = await client.gift_stars("@unknown", amount=100) diff --git a/examples/client_init.py b/examples/client_init.py index e81ed8d..8fea035 100644 --- a/examples/client_init.py +++ b/examples/client_init.py @@ -7,7 +7,7 @@ wallet_version defaults to "V5R1" — change to "V4R2" for older wallets. import asyncio -from fragmentapi import FragmentClient +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" diff --git a/examples/gift_premium.py b/examples/gift_premium.py index 661843d..d36ffab 100644 --- a/examples/gift_premium.py +++ b/examples/gift_premium.py @@ -7,7 +7,7 @@ Set show_sender=False to send anonymously. import asyncio -from fragmentapi import ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/gift_stars.py b/examples/gift_stars.py index 3f5ef7f..0849edd 100644 --- a/examples/gift_stars.py +++ b/examples/gift_stars.py @@ -7,7 +7,7 @@ Set show_sender=False to send anonymously. import asyncio -from fragmentapi import ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/examples/topup_ton.py b/examples/topup_ton.py index abf2abf..f362256 100644 --- a/examples/topup_ton.py +++ b/examples/topup_ton.py @@ -7,7 +7,7 @@ Your wallet must hold at least the topup amount + ~0.056 TON for gas. import asyncio -from fragmentapi import ConfigurationError, FragmentClient, UserNotFoundError, WalletError +from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError, WalletError SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" diff --git a/fragmentapi/methods/__init__.py b/fragmentapi/methods/__init__.py deleted file mode 100644 index b0be4ac..0000000 --- a/fragmentapi/methods/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from fragmentapi.methods.premium import gift_premium -from fragmentapi.methods.stars import gift_stars -from fragmentapi.methods.ton import topup_ton - -__all__ = ["gift_premium", "gift_stars", "topup_ton"] diff --git a/fragmentapi/__init__.py b/pyfragment/__init__.py similarity index 91% rename from fragmentapi/__init__.py rename to pyfragment/__init__.py index 367e191..31f8445 100644 --- a/fragmentapi/__init__.py +++ b/pyfragment/__init__.py @@ -3,8 +3,8 @@ # This source code is licensed under the MIT License found in the # LICENSE file in the root directory of this source tree. -from fragmentapi.client import FragmentClient -from fragmentapi.types import ( +from pyfragment.client import FragmentClient +from pyfragment.types import ( AdsTopupResult, ClientError, ConfigurationError, diff --git a/fragmentapi/client.py b/pyfragment/client.py similarity index 95% rename from fragmentapi/client.py rename to pyfragment/client.py index d402bb4..9884e2e 100644 --- a/fragmentapi/client.py +++ b/pyfragment/client.py @@ -1,9 +1,9 @@ import json -from fragmentapi.methods.premium import gift_premium -from fragmentapi.methods.stars import gift_stars -from fragmentapi.methods.ton import topup_ton -from fragmentapi.types import ( +from pyfragment.methods.premium import gift_premium +from pyfragment.methods.stars import gift_stars +from pyfragment.methods.ton import topup_ton +from pyfragment.types import ( REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, AdsTopupResult, diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py new file mode 100644 index 0000000..82da1b2 --- /dev/null +++ b/pyfragment/methods/__init__.py @@ -0,0 +1,5 @@ +from pyfragment.methods.premium import gift_premium +from pyfragment.methods.stars import gift_stars +from pyfragment.methods.ton import topup_ton + +__all__ = ["gift_premium", "gift_stars", "topup_ton"] diff --git a/fragmentapi/methods/premium.py b/pyfragment/methods/premium.py similarity index 96% rename from fragmentapi/methods/premium.py rename to pyfragment/methods/premium.py index 958b6e7..5338227 100644 --- a/fragmentapi/methods/premium.py +++ b/pyfragment/methods/premium.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING import httpx -from fragmentapi.types import ( +from pyfragment.types import ( BASE_HEADERS, DEVICE, PREMIUM_PAGE, @@ -15,7 +15,7 @@ from fragmentapi.types import ( UnexpectedError, UserNotFoundError, ) -from fragmentapi.utils import ( +from pyfragment.utils import ( execute_transaction_request, fragment_post, get_account_info, @@ -24,7 +24,7 @@ from fragmentapi.utils import ( ) if TYPE_CHECKING: - from fragmentapi.client import FragmentClient + from pyfragment.client import FragmentClient # Page-specific headers HEADERS: dict[str, str] = { diff --git a/fragmentapi/methods/stars.py b/pyfragment/methods/stars.py similarity index 96% rename from fragmentapi/methods/stars.py rename to pyfragment/methods/stars.py index d5b4b50..c605e8d 100644 --- a/fragmentapi/methods/stars.py +++ b/pyfragment/methods/stars.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING import httpx -from fragmentapi.types import ( +from pyfragment.types import ( BASE_HEADERS, DEVICE, STARS_PAGE, @@ -14,7 +14,7 @@ from fragmentapi.types import ( UnexpectedError, UserNotFoundError, ) -from fragmentapi.utils import ( +from pyfragment.utils import ( execute_transaction_request, fragment_post, get_account_info, @@ -23,7 +23,7 @@ from fragmentapi.utils import ( ) if TYPE_CHECKING: - from fragmentapi.client import FragmentClient + from pyfragment.client import FragmentClient # Page-specific headers HEADERS: dict[str, str] = { diff --git a/fragmentapi/methods/ton.py b/pyfragment/methods/ton.py similarity index 96% rename from fragmentapi/methods/ton.py rename to pyfragment/methods/ton.py index 6cd7ebc..ef5871b 100644 --- a/fragmentapi/methods/ton.py +++ b/pyfragment/methods/ton.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING import httpx -from fragmentapi.types import ( +from pyfragment.types import ( BASE_HEADERS, DEVICE, TON_PAGE, @@ -14,7 +14,7 @@ from fragmentapi.types import ( UnexpectedError, UserNotFoundError, ) -from fragmentapi.utils import ( +from pyfragment.utils import ( execute_transaction_request, fragment_post, get_account_info, @@ -23,7 +23,7 @@ from fragmentapi.utils import ( ) if TYPE_CHECKING: - from fragmentapi.client import FragmentClient + from pyfragment.client import FragmentClient # Page-specific headers HEADERS: dict[str, str] = { diff --git a/fragmentapi/types/__init__.py b/pyfragment/types/__init__.py similarity index 87% rename from fragmentapi/types/__init__.py rename to pyfragment/types/__init__.py index a717ccc..5cb878e 100644 --- a/fragmentapi/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -1,4 +1,4 @@ -from fragmentapi.types.constants import ( +from pyfragment.types.constants import ( BASE_HEADERS, DEVICE, MIN_TON_BALANCE, @@ -10,7 +10,7 @@ from fragmentapi.types.constants import ( WALLET_CLASSES, WalletVersion, ) -from fragmentapi.types.exceptions import ( +from pyfragment.types.exceptions import ( ClientError, ConfigurationError, CookieError, @@ -25,7 +25,7 @@ from fragmentapi.types.exceptions import ( VerificationError, WalletError, ) -from fragmentapi.types.results import AdsTopupResult, PremiumResult, StarsResult +from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult __all__ = [ # constants diff --git a/fragmentapi/types/constants.py b/pyfragment/types/constants.py similarity index 100% rename from fragmentapi/types/constants.py rename to pyfragment/types/constants.py diff --git a/fragmentapi/types/exceptions.py b/pyfragment/types/exceptions.py similarity index 98% rename from fragmentapi/types/exceptions.py rename to pyfragment/types/exceptions.py index e1c800d..6a8de78 100644 --- a/fragmentapi/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -1,5 +1,5 @@ class FragmentError(Exception): - """Base exception for all fragmentapi library errors.""" + """Base exception for all pyfragment library errors.""" class ClientError(FragmentError): diff --git a/fragmentapi/types/results.py b/pyfragment/types/results.py similarity index 100% rename from fragmentapi/types/results.py rename to pyfragment/types/results.py diff --git a/fragmentapi/utils/__init__.py b/pyfragment/utils/__init__.py similarity index 64% rename from fragmentapi/utils/__init__.py rename to pyfragment/utils/__init__.py index a888ac8..59d3693 100644 --- a/fragmentapi/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,11 +1,11 @@ -from fragmentapi.utils.decoder import clean_decode -from fragmentapi.utils.http import ( +from pyfragment.utils.decoder import clean_decode +from pyfragment.utils.http import ( execute_transaction_request, fragment_post, get_fragment_hash, parse_json_response, ) -from fragmentapi.utils.wallet import get_account_info, process_transaction +from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", diff --git a/fragmentapi/utils/decoder.py b/pyfragment/utils/decoder.py similarity index 96% rename from fragmentapi/utils/decoder.py rename to pyfragment/utils/decoder.py index b7130ee..451a7b6 100644 --- a/fragmentapi/utils/decoder.py +++ b/pyfragment/utils/decoder.py @@ -2,7 +2,7 @@ import base64 from pytoniq_core import Cell -from fragmentapi.types import ParseError +from pyfragment.types import ParseError def clean_decode(payload: str) -> str: diff --git a/fragmentapi/utils/http.py b/pyfragment/utils/http.py similarity index 98% rename from fragmentapi/utils/http.py rename to pyfragment/utils/http.py index 07087d4..a1a5e1f 100644 --- a/fragmentapi/utils/http.py +++ b/pyfragment/utils/http.py @@ -3,7 +3,7 @@ from typing import Any import httpx -from fragmentapi.types import FragmentPageError, ParseError, VerificationError +from pyfragment.types import FragmentPageError, ParseError, VerificationError async def get_fragment_hash( diff --git a/fragmentapi/utils/wallet.py b/pyfragment/utils/wallet.py similarity index 95% rename from fragmentapi/utils/wallet.py rename to pyfragment/utils/wallet.py index 01a0939..57ae832 100644 --- a/fragmentapi/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -4,11 +4,11 @@ from typing import TYPE_CHECKING, Any from tonutils.clients import TonapiClient from tonutils.types import NetworkGlobalID -from fragmentapi.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError -from fragmentapi.utils.decoder import clean_decode +from pyfragment.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError +from pyfragment.utils.decoder import clean_decode if TYPE_CHECKING: - from fragmentapi.client import FragmentClient + from pyfragment.client import FragmentClient def _init_ton_client(client: "FragmentClient") -> TonapiClient: diff --git a/pyproject.toml b/pyproject.toml index d62ec9c..b96a1f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "fragmentapi" +name = "pyfragment" version = "2026.1.0" description = "Python library for the Fragment.com API — gift Telegram Stars, Premium, and top up TON Ads balance." readme = "README.md" @@ -39,12 +39,12 @@ dev = [ ] [project.urls] -Homepage = "https://github.com/bohd4nx/FragmentAPI" -Repository = "https://github.com/bohd4nx/FragmentAPI" -Issues = "https://github.com/bohd4nx/FragmentAPI/issues" +Homepage = "https://github.com/bohd4nx/pyfragment" +Repository = "https://github.com/bohd4nx/pyfragment" +Issues = "https://github.com/bohd4nx/pyfragment/issues" [tool.hatch.build.targets.wheel] -packages = ["fragmentapi"] +packages = ["pyfragment"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 7ccbe89..170fa1e 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -4,8 +4,8 @@ import re import pytest -from fragmentapi.types import ParseError -from fragmentapi.utils.decoder import clean_decode +from pyfragment.types import ParseError +from pyfragment.utils.decoder import clean_decode PAYLOADS = [ pytest.param( diff --git a/tests/002_test_client.py b/tests/002_test_client.py index ea6e42c..d62f144 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -4,8 +4,8 @@ import json import pytest -from fragmentapi import FragmentClient -from fragmentapi.types import ConfigurationError, CookieError +from pyfragment import FragmentClient +from pyfragment.types import ConfigurationError, CookieError VALID_SEED = "abandon " * 23 + "about" VALID_API_KEY = "test_api_key" diff --git a/tests/003_test_hash.py b/tests/003_test_hash.py index 0140f09..4bb6f18 100644 --- a/tests/003_test_hash.py +++ b/tests/003_test_hash.py @@ -5,8 +5,8 @@ import re import pytest -from fragmentapi.types import BASE_HEADERS, STARS_PAGE -from fragmentapi.utils import get_fragment_hash +from pyfragment.types import BASE_HEADERS, STARS_PAGE +from pyfragment.utils import get_fragment_hash @pytest.mark.asyncio From d8f0240807f9aab2f5c989aa6a84f4462d3f054b Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 15 Mar 2026 22:41:52 +0200 Subject: [PATCH 05/14] feat: add wallet management features and enhance error handling; update tests and examples --- .github/workflows/lint.yml | 4 +- .github/workflows/publish.yml | 4 +- .gitignore | 1 - README.md | 1 + examples/{client_init.py => get_wallet.py} | 6 +- pyfragment/__init__.py | 7 ++ pyfragment/client.py | 16 +++ pyfragment/types/__init__.py | 3 +- pyfragment/types/exceptions.py | 4 +- pyfragment/types/results.py | 11 +- pyfragment/utils/wallet.py | 46 ++++++-- tests/002_test_client.py | 13 +++ tests/004_test_balance.py | 127 +++++++++++++++++++++ 13 files changed, 225 insertions(+), 18 deletions(-) rename examples/{client_init.py => get_wallet.py} (81%) create mode 100644 tests/004_test_balance.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ed1ead3..2a60631 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,9 +2,9 @@ name: Lint on: push: - branches: [master] + branches: ["**"] pull_request: - branches: [master] + branches: ["**"] jobs: lint: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d8955a1..d0f7ae0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,7 +2,9 @@ name: Publish to PyPI on: push: - branches: [master] + branches: ["**"] + pull_request: + branches: ["**"] jobs: build: diff --git a/.gitignore b/.gitignore index 082dca9..ca55ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ logs/ # System files .DS_Store Thumbs.db -cookies.json # Testing & tooling artifacts .hypothesis/ diff --git a/README.md b/README.md index 2e920ed..3e0ed5f 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ See the [`examples/`](examples/) folder for ready-to-run scripts. | `gift_premium(username, months, show_sender=True)` | `PremiumResult` | Gift Telegram Premium subscription | `months`: 3, 6, or 12 | | `gift_stars(username, amount, show_sender=True)` | `StarsResult` | Gift 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 | — | --- diff --git a/examples/client_init.py b/examples/get_wallet.py similarity index 81% rename from examples/client_init.py rename to examples/get_wallet.py index 8fea035..936636e 100644 --- a/examples/client_init.py +++ b/examples/get_wallet.py @@ -27,9 +27,13 @@ async def main() -> None: wallet_version="V5R1", # or "V4R2" ) + wallet = await client.get_wallet() + print("FragmentClient initialized") print(" %-16s %s" % ("Wallet version:", client.wallet_version)) - print(" %-16s %s..." % ("API key:", client.api_key[:8])) + print(" %-16s %s" % ("Address:", wallet.address)) + print(" %-16s %s" % ("State:", wallet.state)) + print(" %-16s %s TON" % ("Balance:", wallet.balance)) if __name__ == "__main__": diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index 31f8445..9495146 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -3,6 +3,8 @@ # 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, @@ -21,13 +23,18 @@ from pyfragment.types import ( UserNotFoundError, VerificationError, WalletError, + WalletInfo, ) +__version__: str = version("pyfragment") + __all__ = [ + "__version__", "FragmentClient", "AdsTopupResult", "PremiumResult", "StarsResult", + "WalletInfo", "ClientError", "ConfigurationError", "CookieError", diff --git a/pyfragment/client.py b/pyfragment/client.py index 9884e2e..677f90a 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -11,8 +11,10 @@ from pyfragment.types import ( CookieError, PremiumResult, StarsResult, + WalletInfo, WalletVersion, ) +from pyfragment.utils.wallet import get_wallet_info class FragmentClient: @@ -36,6 +38,7 @@ class FragmentClient: api_key="AAABBB...", cookies={"stel_ssid": "...", "stel_dt": "...", ...}, ) + print(await client.get_wallet()) result = await client.gift_premium("@username", months=6) print(result.transaction_id) """ @@ -51,6 +54,10 @@ class FragmentClient: 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 isinstance(cookies, str): try: cookies = json.loads(cookies) @@ -112,3 +119,12 @@ class FragmentClient: :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/types/__init__.py b/pyfragment/types/__init__.py index 5cb878e..c254997 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -25,7 +25,7 @@ from pyfragment.types.exceptions import ( VerificationError, WalletError, ) -from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult +from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo __all__ = [ # constants @@ -58,4 +58,5 @@ __all__ = [ "AdsTopupResult", "PremiumResult", "StarsResult", + "WalletInfo", ] diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 6a8de78..b25fdbe 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -11,6 +11,7 @@ class ConfigurationError(ClientError): 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_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." @@ -78,9 +79,10 @@ class OperationError(FragmentError): class WalletError(OperationError): """Raised for TON wallet issues (connection, balance, account info).""" - LOW_BALANCE = "TON wallet balance is too low: {balance:.2f} TON. Minimum required is 0.056 TON." + 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): diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index 234f7cc..55d4add 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -1,7 +1,16 @@ import time from dataclasses import dataclass, field -__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult"] +__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult", "WalletInfo"] + + +@dataclass +class WalletInfo: + """Wallet state returned by :meth:`FragmentClient.get_wallet`.""" + + address: str + state: str + balance: float @dataclass diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 57ae832..3a8fded 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -5,16 +5,13 @@ from tonutils.clients import TonapiClient 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 -def _init_ton_client(client: "FragmentClient") -> TonapiClient: - return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) - - async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str: """Sign and broadcast a Fragment transaction to the TON network. @@ -35,26 +32,29 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) 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 _init_ton_client(client) as ton: + 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 before broadcasting + # Check balance covers transaction amount + gas reserve try: await wallet.refresh() balance_ton = wallet.balance / 1_000_000_000 - if balance_ton < MIN_TON_BALANCE: - raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton)) + 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: - message = transaction_data["transaction"]["messages"][0] payload = clean_decode(message["payload"]) result = await wallet.transfer( @@ -85,7 +85,7 @@ async def get_account_info(client: "FragmentClient") -> dict[str, Any]: Raises: WalletError: If account info cannot be retrieved. """ - async with _init_ton_client(client) as ton: + 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) @@ -98,3 +98,29 @@ async def get_account_info(client: "FragmentClient") -> dict[str, Any]: } 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/tests/002_test_client.py b/tests/002_test_client.py index d62f144..79b7406 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -79,3 +79,16 @@ 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 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": {}}) From f4a96bb01aa8da7cff43df8afa1c7e8318f1bdd6 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 15 Mar 2026 22:45:00 +0200 Subject: [PATCH 06/14] fix: restrict workflow triggers to the master branch for linting and publishing --- .github/workflows/lint.yml | 4 ++-- .github/workflows/publish.yml | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2a60631..ed1ead3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,9 +2,9 @@ name: Lint on: push: - branches: ["**"] + branches: [master] pull_request: - branches: ["**"] + branches: [master] jobs: lint: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d0f7ae0..1d96144 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,9 +2,7 @@ name: Publish to PyPI on: push: - branches: ["**"] - pull_request: - branches: ["**"] + branches: [master] jobs: build: @@ -34,6 +32,7 @@ jobs: name: Publish needs: build runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' environment: name: pypi url: https://pypi.org/project/pyfragment/ From 041081b919d8bea897c02fa581124ced02017939 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 15 Mar 2026 23:01:37 +0200 Subject: [PATCH 07/14] feat: add API key validation and corresponding error handling; update tests for validation --- .gitignore | 1 + pyfragment/client.py | 3 +++ pyfragment/types/exceptions.py | 1 + pyfragment/utils/wallet.py | 21 +++++++++++++++------ pyproject.toml | 6 +++--- tests/002_test_client.py | 7 ++++++- 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index ca55ba5..cda7104 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ Thumbs.db .ruff_cache/ .coverage htmlcov/ +demo.run.py # Build & distribution dist/ diff --git a/pyfragment/client.py b/pyfragment/client.py index 677f90a..3618632 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -58,6 +58,9 @@ class FragmentClient: 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) diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index b25fdbe..435a50c 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -12,6 +12,7 @@ class ConfigurationError(ClientError): 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." diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 3a8fded..55680f2 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -1,7 +1,9 @@ +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 @@ -57,12 +59,19 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) try: payload = clean_decode(message["payload"]) - result = await wallet.transfer( - destination=message["address"], - amount=int(message["amount"]), # nanotons, not TON - body=payload, - ) - return result.normalized_hash + 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: diff --git a/pyproject.toml b/pyproject.toml index b96a1f0..1f6ab66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,13 +27,13 @@ classifiers = [ ] dependencies = [ "httpx==0.28.1", - "tonutils[pytoniq]==2.0.0", + "tonutils[pytoniq]==2.0.4", ] [project.optional-dependencies] dev = [ - "pytest>=9.0", - "pytest-asyncio>=1.0", + "pytest==9.0.2", + "pytest-asyncio==1.3.0", "ruff", "black", ] diff --git a/tests/002_test_client.py b/tests/002_test_client.py index 79b7406..152ae6d 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -8,7 +8,7 @@ from pyfragment import FragmentClient from pyfragment.types import ConfigurationError, CookieError VALID_SEED = "abandon " * 23 + "about" -VALID_API_KEY = "test_api_key" +VALID_API_KEY = "A" * 68 VALID_COOKIES = { "stel_ssid": "x", "stel_dt": "x", @@ -92,3 +92,8 @@ def test_valid_mnemonic_lengths() -> None: 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) From 67f8a882c21808841f07c73d6120a87955328df5 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 15 Mar 2026 23:05:22 +0200 Subject: [PATCH 08/14] feat: add MIT License to the project --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE 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. From 520153dfb113e4af411866d3056a01d2a34b64bb Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Mon, 16 Mar 2026 01:14:12 +0200 Subject: [PATCH 09/14] feat: update terminology from "gift" to "purchase" for Telegram Premium and Stars; enhance README and example scripts --- README.md | 18 ++++++++++-------- examples/gift_premium.py | 4 ++-- examples/gift_stars.py | 4 ++-- pyfragment/client.py | 10 +++++++--- pyproject.toml | 2 +- 5 files changed, 22 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 3e0ed5f..6640ce6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@
Fragment Logo -

💎 Fragment API

+

Fragment API

- Python library for the Fragment.com API — gift Telegram Stars, Premium, and top up TON Ads balance. + Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance.

[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) @@ -18,13 +18,15 @@
+> **Disclaimer:** This project is not affiliated with, endorsed by, or in any way officially connected with [Fragment](https://fragment.com) or [Telegram](https://telegram.org). + --- ## ✨ Features - 💰 **TON Advertisement Topups** — Top up Telegram Ads balance (1–1,000,000,000 TON) -- 👑 **Telegram Premium Gifts** — Gift Premium to any user (3, 6, or 12 months) -- ⭐ **Telegram Stars Purchases** — Gift Stars to any Telegram user (50–1,000,000 Stars) +- 👑 **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` @@ -58,11 +60,11 @@ client = FragmentClient( ) async def main(): - # Gift 6 months of Telegram Premium + # Purchase 6 months of Telegram Premium result = await client.gift_premium("@username", months=6) print(result.transaction_id) - # Gift 500 Stars + # Purchase 500 Stars result = await client.gift_stars("@username", amount=500) print(result.transaction_id) @@ -94,8 +96,8 @@ See the [`examples/`](examples/) folder for ready-to-run scripts. | Method | Returns | Description | Limits | | -------------------------------------------------- | ---------------- | ---------------------------------- | ------------------------- | -| `gift_premium(username, months, show_sender=True)` | `PremiumResult` | Gift Telegram Premium subscription | `months`: 3, 6, or 12 | -| `gift_stars(username, amount, show_sender=True)` | `StarsResult` | Gift Telegram Stars | `amount`: 50–1,000,000 | +| `gift_premium(username, months, show_sender=True)` | `PremiumResult` | Purchase Telegram Premium | `months`: 3, 6, or 12 | +| `gift_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 | — | diff --git a/examples/gift_premium.py b/examples/gift_premium.py index d36ffab..16b8395 100644 --- a/examples/gift_premium.py +++ b/examples/gift_premium.py @@ -1,5 +1,5 @@ """ -Example: gift Telegram Premium to a user. +Example: purchase Telegram Premium for a user. Supported durations: 3, 6, or 12 months. Set show_sender=False to send anonymously. @@ -34,7 +34,7 @@ async def main() -> None: print(f"Invalid parameters: {e}") return - print("Premium gifted") + print("Premium purchased") print(" %-14s %s" % ("Username:", result.username)) print(" %-14s %s months" % ("Duration:", result.months)) print(" %-14s %s" % ("Transaction:", result.transaction_id)) diff --git a/examples/gift_stars.py b/examples/gift_stars.py index 0849edd..e9500b9 100644 --- a/examples/gift_stars.py +++ b/examples/gift_stars.py @@ -1,5 +1,5 @@ """ -Example: gift Telegram Stars to a user. +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. @@ -34,7 +34,7 @@ async def main() -> None: print(f"Invalid parameters: {e}") return - print("Stars gifted") + print("Stars purchased") print(" %-14s %s" % ("Username:", result.username)) print(" %-14s %s" % ("Stars:", result.stars)) print(" %-14s %s" % ("Transaction:", result.transaction_id)) diff --git a/pyfragment/client.py b/pyfragment/client.py index 3618632..0c33fce 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -21,6 +21,10 @@ 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. @@ -85,12 +89,12 @@ class FragmentClient: self.wallet_version: WalletVersion = version # type: ignore[assignment] async def gift_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult: - """Gift Telegram Premium to a user. + """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 gift sender. Defaults to ``True``. + show_sender: Show your name as the sender. Defaults to ``True``. Returns: :class:`PremiumResult` with ``transaction_id``, ``username``, ``months``, ``timestamp``. @@ -98,7 +102,7 @@ class FragmentClient: return await gift_premium(self, username, months, show_sender) async def gift_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult: - """Gift Telegram Stars to a user. + """Purchase Telegram Stars for a user. Args: username: Recipient's Telegram username (with or without ``@``). diff --git a/pyproject.toml b/pyproject.toml index 1f6ab66..dadce56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" version = "2026.1.0" -description = "Python library for the Fragment.com API — gift Telegram Stars, Premium, and top up TON Ads balance." +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" From f5d2e8490aa8a1432593499c5920a0644d9eda43 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Mon, 16 Mar 2026 18:22:58 +0200 Subject: [PATCH 10/14] feat: restructure GitHub workflows; add CI workflow, update publish process, and remove obsolete test and lint workflows --- .github/dependabot.yml | 9 ++++ .github/workflows/ci.yml | 50 +++++++++++++++++++++ .github/workflows/lint.yml | 31 -------------- .github/workflows/publish.yml | 81 ++++++++++++++++++++++++++++------- .github/workflows/tests.yml | 34 --------------- CHANGELOG.md | 23 ++++++++++ README.md | 2 +- pyfragment/__init__.py | 2 +- pyfragment/py.typed | 0 pyproject.toml | 3 +- 10 files changed, 152 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/tests.yml create mode 100644 CHANGELOG.md create mode 100644 pyfragment/py.typed 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/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index ed1ead3..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Lint - -on: - push: - branches: [master] - pull_request: - branches: [master] - -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" - - - name: Install uv - uses: astral-sh/setup-uv@v7.5.0 - - - name: Install dev dependencies - run: uv pip install --system ".[dev]" - - - name: Run ruff - run: ruff check . - - - name: Run black - run: black --check . --target-version py312 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1d96144..1b575e9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,12 +1,44 @@ -name: Publish to PyPI +name: Publish on: - push: + 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: @@ -16,23 +48,19 @@ jobs: with: python-version: "3.12" - - name: Install uv - uses: astral-sh/setup-uv@v7.5.0 + - uses: astral-sh/setup-uv@v7.5.0 - - name: Build distribution - run: uv build + - run: uv build - - name: Upload artifacts - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@v7 with: name: dist path: dist/* publish: - name: Publish - needs: build + name: Publish to PyPI + needs: [version-check, build] runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' environment: name: pypi url: https://pypi.org/project/pyfragment/ @@ -40,11 +68,34 @@ jobs: id-token: write steps: - - name: Download artifacts - uses: actions/download-artifact@v8.0.1 + - uses: actions/download-artifact@v8.0.1 with: name: dist path: dist - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.13.0 + - 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 4edd34f..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Tests - -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - -jobs: - test: - name: Run tests - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6.0.2 - - - uses: actions/setup-python@v6.2.0 - with: - python-version: "3.12" - - - name: Install uv - uses: astral-sh/setup-uv@v7.5.0 - - - name: Install dependencies - 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 }} - - - name: Run tests - run: pytest diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..48134f1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# 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 +- `gift_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months) +- `gift_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 + +[2026.0.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.1 diff --git a/README.md b/README.md index 6640ce6..5ae7d3d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![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/tests.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions) +[![CI](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions) [Report Bug](https://github.com/bohd4nx/pyfragment/issues) · [Request Feature](https://github.com/bohd4nx/pyfragment/issues) · [**Donate TON**](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5) diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index 9495146..d08f26e 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 bohd4nx +# 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. diff --git a/pyfragment/py.typed b/pyfragment/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index dadce56..1f41d60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" -version = "2026.1.0" +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" } @@ -42,6 +42,7 @@ dev = [ 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"] From 8d58ed890dc1f484a85aa7bb9b09dd7236f3b985 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Mon, 16 Mar 2026 18:41:27 +0200 Subject: [PATCH 11/14] feat: add examples for purchasing Telegram Premium and Stars --- examples/{gift_premium.py => purchase_premium.py} | 0 examples/{gift_stars.py => purchase_stars.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename examples/{gift_premium.py => purchase_premium.py} (100%) rename examples/{gift_stars.py => purchase_stars.py} (100%) diff --git a/examples/gift_premium.py b/examples/purchase_premium.py similarity index 100% rename from examples/gift_premium.py rename to examples/purchase_premium.py diff --git a/examples/gift_stars.py b/examples/purchase_stars.py similarity index 100% rename from examples/gift_stars.py rename to examples/purchase_stars.py From b726a2274c7a4fe96bdd55b6061e05d0e056d38c Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Mon, 16 Mar 2026 19:00:55 +0200 Subject: [PATCH 12/14] feat: update bug report template to reflect terminology change from "gift" to "purchase" for Stars --- .github/ISSUE_TEMPLATE/bug.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml index afc89fd..8006d94 100644 --- a/.github/ISSUE_TEMPLATE/bug.yaml +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -45,7 +45,7 @@ body: attributes: label: Expected behavior description: Describe what you expected to happen. - placeholder: e.g. Stars should be gifted and StarsResult returned. + placeholder: e.g. Stars should be purchased and StarsResult returned. validations: required: true @@ -63,7 +63,7 @@ body: description: Minimal steps that reproduce the issue. placeholder: | 1. Create FragmentClient with valid credentials - 2. Call gift_stars("@username", amount=100) + 2. Call purchase_stars("@username", amount=100) 3. See error validations: required: true @@ -78,7 +78,7 @@ body: async def main(): client = FragmentClient(...) - result = await client.gift_stars("@username", amount=100) + result = await client.purchase_stars("@username", amount=100) asyncio.run(main()) render: python From 20b73444ab70dd5acfad8ef61a3b3c1803f6b83d Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Mon, 16 Mar 2026 19:13:07 +0200 Subject: [PATCH 13/14] feat: rename gift methods to purchase for clarity; update examples and changelog --- CHANGELOG.md | 4 ++-- README.md | 12 ++++++------ examples/purchase_premium.py | 2 +- examples/purchase_stars.py | 2 +- pyfragment/client.py | 14 +++++++------- pyfragment/methods/__init__.py | 6 +++--- pyfragment/methods/premium.py | 2 +- pyfragment/methods/stars.py | 2 +- pyfragment/types/results.py | 12 ++++++++++++ 9 files changed, 34 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48134f1..64b5ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,8 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI ### Added - Initial stable release of `pyfragment` - `FragmentClient` — async client for the Fragment.com API -- `gift_premium(username, months)` — purchase Telegram Premium for any user (3, 6, or 12 months) -- `gift_stars(username, amount)` — send Telegram Stars to any user (50–1,000,000) +- `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` diff --git a/README.md b/README.md index 5ae7d3d..9bb94ce 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,11 @@ client = FragmentClient( async def main(): # Purchase 6 months of Telegram Premium - result = await client.gift_premium("@username", months=6) + result = await client.purchase_premium("@username", months=6) print(result.transaction_id) # Purchase 500 Stars - result = await client.gift_stars("@username", amount=500) + result = await client.purchase_stars("@username", amount=500) print(result.transaction_id) # Top up 10 TON to Ads balance @@ -96,8 +96,8 @@ See the [`examples/`](examples/) folder for ready-to-run scripts. | Method | Returns | Description | Limits | | -------------------------------------------------- | ---------------- | ---------------------------------- | ------------------------- | -| `gift_premium(username, months, show_sender=True)` | `PremiumResult` | Purchase Telegram Premium | `months`: 3, 6, or 12 | -| `gift_stars(username, amount, show_sender=True)` | `StarsResult` | Purchase Telegram Stars | `amount`: 50–1,000,000 | +| `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 | — | @@ -111,7 +111,7 @@ See the [`examples/`](examples/) folder for ready-to-run scripts. 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** → **JSON** +3. Click the extension → **Export** → **Header String** 4. Extract these four fields: ```json @@ -155,7 +155,7 @@ All exceptions inherit from `FragmentError` — see [`pyfragment/types/exception from pyfragment import FragmentClient, UserNotFoundError, ConfigurationError, WalletError try: - result = await client.gift_stars("@unknown", amount=100) + result = await client.purchase_stars("@unknown", amount=100) except UserNotFoundError: print("User not found on Fragment") except WalletError as e: diff --git a/examples/purchase_premium.py b/examples/purchase_premium.py index 16b8395..d4366f2 100644 --- a/examples/purchase_premium.py +++ b/examples/purchase_premium.py @@ -26,7 +26,7 @@ async def main() -> None: client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) try: - result = await client.gift_premium(USERNAME, months=MONTHS, show_sender=True) + result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True) except UserNotFoundError: print(f"User {USERNAME!r} not found on Fragment.") return diff --git a/examples/purchase_stars.py b/examples/purchase_stars.py index e9500b9..c0902a4 100644 --- a/examples/purchase_stars.py +++ b/examples/purchase_stars.py @@ -26,7 +26,7 @@ async def main() -> None: client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) try: - result = await client.gift_stars(USERNAME, amount=AMOUNT, show_sender=True) + result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True) except UserNotFoundError: print(f"User {USERNAME!r} not found on Fragment.") return diff --git a/pyfragment/client.py b/pyfragment/client.py index 0c33fce..3927e0d 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -1,7 +1,7 @@ import json -from pyfragment.methods.premium import gift_premium -from pyfragment.methods.stars import gift_stars +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, @@ -43,7 +43,7 @@ class FragmentClient: cookies={"stel_ssid": "...", "stel_dt": "...", ...}, ) print(await client.get_wallet()) - result = await client.gift_premium("@username", months=6) + result = await client.purchase_premium("@username", months=6) print(result.transaction_id) """ @@ -88,7 +88,7 @@ class FragmentClient: self.cookies: dict = cookies self.wallet_version: WalletVersion = version # type: ignore[assignment] - async def gift_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult: + async def purchase_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult: """Purchase Telegram Premium for a user. Args: @@ -99,9 +99,9 @@ class FragmentClient: Returns: :class:`PremiumResult` with ``transaction_id``, ``username``, ``months``, ``timestamp``. """ - return await gift_premium(self, username, months, show_sender) + return await purchase_premium(self, username, months, show_sender) - async def gift_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult: + async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult: """Purchase Telegram Stars for a user. Args: @@ -112,7 +112,7 @@ class FragmentClient: Returns: :class:`StarsResult` with ``transaction_id``, ``username``, ``stars``, ``timestamp``. """ - return await gift_stars(self, username, amount, show_sender) + 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. diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py index 82da1b2..c0f4e2e 100644 --- a/pyfragment/methods/__init__.py +++ b/pyfragment/methods/__init__.py @@ -1,5 +1,5 @@ -from pyfragment.methods.premium import gift_premium -from pyfragment.methods.stars import gift_stars +from pyfragment.methods.premium import purchase_premium +from pyfragment.methods.stars import purchase_stars from pyfragment.methods.ton import topup_ton -__all__ = ["gift_premium", "gift_stars", "topup_ton"] +__all__ = ["purchase_premium", "purchase_stars", "topup_ton"] diff --git a/pyfragment/methods/premium.py b/pyfragment/methods/premium.py index 5338227..0c19c6b 100644 --- a/pyfragment/methods/premium.py +++ b/pyfragment/methods/premium.py @@ -89,7 +89,7 @@ async def _init_request( return req_id -async def gift_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult: +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) diff --git a/pyfragment/methods/stars.py b/pyfragment/methods/stars.py index c605e8d..bc58fa8 100644 --- a/pyfragment/methods/stars.py +++ b/pyfragment/methods/stars.py @@ -76,7 +76,7 @@ async def _init_request( return req_id -async def gift_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult: +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) diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index 55d4add..a6ad138 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -12,6 +12,9 @@ class WalletInfo: state: str balance: float + def __repr__(self) -> str: + return f"WalletInfo(address='{self.address}', state='{self.state}', balance={self.balance} TON)" + @dataclass class PremiumResult: @@ -22,6 +25,9 @@ class PremiumResult: 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: @@ -32,6 +38,9 @@ class StarsResult: 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: @@ -41,3 +50,6 @@ class AdsTopupResult: 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}')" From c5edfad06f267f91bd75fff23d568352a24f4b56 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Mon, 16 Mar 2026 19:21:43 +0200 Subject: [PATCH 14/14] feat: add context manager support to FragmentClient; update README and tests --- CHANGELOG.md | 3 +- README.md | 39 ++++++++++++----------- pyfragment/client.py | 19 +++++++++--- tests/002_test_client.py | 14 +++++++++ tests/005_test_methods.py | 65 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 26 deletions(-) create mode 100644 tests/005_test_methods.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 64b5ad3..e9ff67c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI ### Added - Initial stable release of `pyfragment` -- `FragmentClient` — async client for the Fragment.com API +- `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) @@ -19,5 +19,6 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - 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/README.md b/README.md index 9bb94ce..ccb7c8b 100644 --- a/README.md +++ b/README.md @@ -48,29 +48,28 @@ Requires **Python 3.12+**. import asyncio from pyfragment import FragmentClient -client = FragmentClient( - seed="word1 word2 ... word24", - api_key="YOUR_TONAPI_KEY", - cookies={ - "stel_ssid": "...", - "stel_dt": "...", - "stel_token": "...", - "stel_ton_token": "...", - }, -) - async def main(): - # Purchase 6 months of Telegram Premium - result = await client.purchase_premium("@username", months=6) - print(result.transaction_id) + 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) + # 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) + # Top up 10 TON to Ads balance + result = await client.topup_ton("@username", amount=10) + print(result.transaction_id) asyncio.run(main()) ``` diff --git a/pyfragment/client.py b/pyfragment/client.py index 3927e0d..09a62df 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -37,14 +37,14 @@ class FragmentClient: Example:: - client = FragmentClient( + async with FragmentClient( seed="word1 word2 ...", api_key="AAABBB...", cookies={"stel_ssid": "...", "stel_dt": "...", ...}, - ) - print(await client.get_wallet()) - result = await client.purchase_premium("@username", months=6) - print(result.transaction_id) + ) as client: + print(await client.get_wallet()) + result = await client.purchase_premium("@username", months=6) + print(result.transaction_id) """ def __init__( @@ -88,6 +88,15 @@ class FragmentClient: 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. diff --git a/tests/002_test_client.py b/tests/002_test_client.py index 152ae6d..ccf10fe 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -97,3 +97,17 @@ def test_valid_mnemonic_lengths() -> None: 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/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)