diff --git a/.env.example b/.env.example index 9abefcb..4b9fd93 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,5 @@ # Fragment.com cookies - copy from browser after login (Header String format) -# How to get fragment hash: open devtools -> Network -> api?hash= -# HASH = "your_fragment_hash_here" --- IGNORE --- -# Hash is now fetched dynamically, so this line is no longer needed. +# Hash is now fetched dynamically # TON wallet seed phrase - 12 or 24 words separated by spaces SEED = "your_ton_wallet_seed_phrase_here" diff --git a/.gitignore b/.gitignore index d8f5d48..0b63ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,4 @@ tests/ # System files .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db diff --git a/app/__meta__.py b/app/__meta__.py deleted file mode 100644 index f5240f6..0000000 --- a/app/__meta__.py +++ /dev/null @@ -1,13 +0,0 @@ -APP_NAME = "FragmentAPI" -APP_TITLE = "Fragment API by @bohd4nx" -APP_VERSION = "2025.1.2" -APP_AUTHOR = "Bohdan (bohd4nx)" -APP_TIMESTAMP = "2025-11-24T12:00:00Z" - -__all__ = [ - "APP_NAME", - "APP_TITLE", - "APP_VERSION", - "APP_AUTHOR", - "APP_TIMESTAMP", -] diff --git a/app/core/__init__.py b/app/core/__init__.py index 00a22a3..537895b 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -1,4 +1,34 @@ -from app.core.config import Config, config +from app.core.config import config +from app.core.constants import ADS_PAGE, BASE_HEADERS, DEVICE, PREMIUM_PAGE, STARS_PAGE +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__ = ["Config", "config", "logger", "setup_logging"] +__all__ = [ + "ADS_PAGE", + "BASE_HEADERS", + "DEVICE", + "PREMIUM_PAGE", + "STARS_PAGE", + "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 index 331989a..6ca9814 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -5,34 +5,38 @@ from pathlib import Path from dotenv import load_dotenv +from app.core.exceptions import ConfigError + logger = logging.getLogger(__name__) class Config: - def __init__(self): + SEED: str + API_KEY: str + + def __init__(self) -> None: env_path = Path(__file__).resolve().parents[2] / ".env" if not env_path.exists(): - logger.error(".env file not found!") - sys.exit(1) + raise ConfigError( + ".env file not found. " + "Copy .env.example to .env and fill in SEED and API_KEY." + ) load_dotenv(env_path) - required_keys = ["SEED", "API_KEY"] - missing_keys: list[str] = [] + 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)}. " + "Open .env and fill in all required fields." + ) - for key in required_keys: - value = os.getenv(key, "").strip() - if not value: - missing_keys.append(key) - setattr(self, key, value) + self.SEED = os.getenv("SEED", "").strip() + self.API_KEY = os.getenv("API_KEY", "").strip() - if missing_keys: - logger.error(f"Missing required environment variables: {', '.join(missing_keys)}") - logger.error("Create .env file based on .env.example and fill all fields") - sys.exit(1) - - logger.info("Configuration loaded successfully") - - -config = Config() +try: + config = Config() +except ConfigError as e: + logger.error("Configuration error: %s", e) + sys.exit(1) diff --git a/app/core/constants.py b/app/core/constants.py new file mode 100644 index 0000000..700116f --- /dev/null +++ b/app/core/constants.py @@ -0,0 +1,38 @@ +import json + +# 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" + +# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. +DEVICE: str = json.dumps({ + "platform": "iphone", + "appName": "Tonkeeper", + "appVersion": "5.5.2", + "maxProtocolVersion": 2, + "features": [ + "SendTransaction", + {"name": "SendTransaction", "maxMessages": 255}, + {"name": "SignData", "types": ["text", "binary", "cell"]}, + ], +}) + +# Base HTTP headers — shared across all Fragment API requests. +# Each method merges these with its own "referer" and "x-aj-referer". +BASE_HEADERS: dict[str, str] = { + "accept": "application/json, text/javascript, */*; q=0.01", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "origin": "https://fragment.com", + "priority": "u=1, i", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "user-agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) " + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1" + ), + "x-requested-with": "XMLHttpRequest", +} diff --git a/app/core/cookies.py b/app/core/cookies.py new file mode 100644 index 0000000..7dc4031 --- /dev/null +++ b/app/core/cookies.py @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..9a59b65 --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,42 @@ +__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 index 2f38067..87be637 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -1,7 +1,5 @@ import logging -from app.__meta__ import APP_NAME - def setup_logging() -> None: formatter = logging.Formatter( @@ -14,7 +12,7 @@ def setup_logging() -> None: console_handler.setFormatter(formatter) file_handler = logging.FileHandler( - f"{APP_NAME}.log", + "FragmentAPI.log", mode="w", encoding="utf-8" ) @@ -26,8 +24,6 @@ def setup_logging() -> None: force=True ) - logging.getLogger("aiogram.dispatcher").setLevel(logging.INFO) - logging.getLogger("aiogram.event").setLevel(logging.ERROR) logging.getLogger("httpx").setLevel(logging.INFO) logging.getLogger("httpcore").setLevel(logging.WARNING) diff --git a/app/methods/__init__.py b/app/methods/__init__.py index fc72eef..ba0e43a 100644 --- a/app/methods/__init__.py +++ b/app/methods/__init__.py @@ -1,5 +1,5 @@ -from app.methods.premium import FragmentPremium -from app.methods.stars import FragmentStars -from app.methods.ton import FragmentTon +from app.methods.premium import buy_premium +from app.methods.stars import buy_stars +from app.methods.ton import topup_ton -__all__ = ['FragmentTon', 'FragmentPremium', 'FragmentStars'] +__all__ = ['buy_premium', 'buy_stars', 'topup_ton'] diff --git a/app/methods/premium.py b/app/methods/premium.py index 21a1772..bfde21c 100644 --- a/app/methods/premium.py +++ b/app/methods/premium.py @@ -1,133 +1,116 @@ -import base64 +import json import logging import time import httpx -from tonutils.client import TonapiClient -from tonutils.wallet import WalletV5R1 -from app.core import config +from app.core import load_cookies +from app.core.constants import BASE_HEADERS, DEVICE, PREMIUM_PAGE +from app.core.exceptions import FragmentError, UserNotFoundError from app.utils import ( - TransactionProcessor, - WalletLinker, - ApiClient, - clean_decode, - parse_json_response, - load_cookies, + 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, +} -class FragmentPremium: - def __init__(self): - self.headers = { - "accept": "application/json, text/javascript, */*; q=0.01", - "accept-encoding": "gzip, deflate, br, zstd", - "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": "https://fragment.com", - "referer": "https://fragment.com/premium/gift", - "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", - "x-requested-with": "XMLHttpRequest", - } - self.cookies = load_cookies() - - self.transaction_processor = TransactionProcessor(clean_decode) - self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor) - self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker) - - @staticmethod - async def _get_account_info(): - client = TonapiClient(api_key=config.API_KEY, is_testnet=False) - wallet, pub_key, _, _ = WalletV5R1.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.hex(), - "chain": "-239", - "walletStateInit": base64.b64encode(boc).decode() - } - - async def buy_premium(self, username, months): - if months not in [3, 6, 12]: - return {"success": False, "error": "Invalid duration. Use 3, 6, or 12 months"} - - fragment_hash = await get_fragment_hash( - self.cookies, - self.headers, - "https://fragment.com/premium/gift", +async def search_premium_recipient( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + username: str, + months: int, +) -> str: + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + 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." ) - if not fragment_hash: - raise RuntimeError("Failed to fetch Fragment hash") + return recipient - account = await self._get_account_info() + +async def init_gift_premium( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + recipient: str, + months: int, +) -> str: + await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + 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, cookies=cookies, + 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) -> dict: + if months not in (3, 6, 12): + return {"success": False, "error": "Invalid duration. Choose 3, 6, or 12 months."} + + try: + cookies = load_cookies() + fragment_hash = await get_fragment_hash(cookies, HEADERS, PREMIUM_PAGE) + account = await get_account_info() async with httpx.AsyncClient() as client: - search_data = {"query": username, "months": months, "method": "searchPremiumGiftRecipient"} - search_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=search_data) - - search_result, error = parse_json_response(search_resp, logger, "search") - if search_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - recipient = search_result.get("found", {}).get("recipient") - if not recipient: - return {"success": False, "error": "User not found"} - - update_data = {"mode": "new", "lv": "false", "dh": str(int(time.time())), "method": "updatePremiumState"} - await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=update_data) - - init_data = {"recipient": recipient, "months": months, "method": "initGiftPremiumRequest"} - init_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=init_data) - - init_result, error = parse_json_response(init_resp, logger, "init") - if init_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - req_id = init_result.get("req_id") - if not req_id: - return {"success": False, "error": "Failed to initialize purchase"} + recipient = await search_premium_recipient(client, fragment_hash, cookies, username, months) + req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months) tx_data = { - 'account': account, - 'device': {"appVersion": "5.4.3", "platform": "iphone", - "features": ["SendTransaction", {"maxMessages": 255, "name": "SendTransaction"}, - {"types": ["text", "binary", "cell"], "name": "SignData"}], - "appName": "Tonkeeper", "maxProtocolVersion": 2}, - 'transaction': 1, - 'id': req_id, - 'show_sender': 1, - 'ref': "OprzztcdJ", - 'method': 'getGiftPremiumLink' + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "show_sender": 1, + "method": "getGiftPremiumLink", } + transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) - request_success, transaction_result = await self.api_client.execute_transaction_request( - tx_data, - account, - fragment_hash, - ) + tx_hash = await process_transaction(transaction) + return { + "success": True, + "data": { + "transaction_id": tx_hash, + "username": username, + "months": months, + "timestamp": int(time.time()), + }, + } - if not request_success: - return transaction_result - - success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result) - - if success: - return { - "success": True, - "data": { - "transaction_id": tx_hash, - "username": username, - "months": months, - "timestamp": int(time.time()) - } - } - - return {"success": False, "error": error} + 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 index 8be0e82..d9f578b 100644 --- a/app/methods/stars.py +++ b/app/methods/stars.py @@ -1,125 +1,110 @@ -import base64 +import json import logging import time import httpx -from tonutils.client import TonapiClient -from tonutils.wallet import WalletV5R1 -from app.core import config +from app.core import load_cookies +from app.core.constants import BASE_HEADERS, DEVICE, STARS_PAGE +from app.core.exceptions import FragmentError, UserNotFoundError from app.utils import ( - TransactionProcessor, - WalletLinker, - ApiClient, - clean_decode, - parse_json_response, - load_cookies, + 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, +} -class FragmentStars: - def __init__(self): - self.headers = { - "accept": "application/json, text/javascript, */*; q=0.01", - "accept-encoding": "gzip, deflate, br, zstd", - "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": "https://fragment.com", - "referer": "https://fragment.com/stars/buy", - "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", - "x-requested-with": "XMLHttpRequest", - } - self.cookies = load_cookies() - - self.transaction_processor = TransactionProcessor(clean_decode) - self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor) - self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker) - - @staticmethod - async def _get_account_info(): - client = TonapiClient(api_key=config.API_KEY, is_testnet=False) - wallet, pub_key, _, _ = WalletV5R1.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.hex(), - "chain": "-239", - "walletStateInit": base64.b64encode(boc).decode() - } - - async def buy_stars(self, username, amount): - if amount < 50 or not isinstance(amount, int): - return {"success": False, "error": "Amount must be an integer >= 50 stars"} - - fragment_hash = await get_fragment_hash( - self.cookies, - self.headers, - "https://fragment.com/stars/buy", +async def search_stars_recipient( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + username: str, +) -> str: + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + 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." ) - if not fragment_hash: - raise RuntimeError("Failed to fetch Fragment hash") + return recipient - account = await self._get_account_info() + +async def init_buy_stars( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + recipient: str, + amount: int, +) -> str: + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + 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) -> dict: + if not isinstance(amount, int) or amount < 50: + return {"success": False, "error": "Amount must be an integer >= 50 stars."} + + try: + cookies = load_cookies() + fragment_hash = await get_fragment_hash(cookies, HEADERS, STARS_PAGE) + account = await get_account_info() async with httpx.AsyncClient() as client: - search_data = {"query": username, "quantity": "", "method": "searchStarsRecipient"} - search_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=search_data) - - search_result, error = parse_json_response(search_resp, logger, "search") - if search_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - recipient = search_result.get("found", {}).get("recipient") - if not recipient: - return {"success": False, "error": "User not found"} - - init_data = {"recipient": recipient, "quantity": amount, "method": "initBuyStarsRequest"} - init_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=init_data) - - init_result, error = parse_json_response(init_resp, logger, "init") - if init_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - req_id = init_result.get("req_id") - if not req_id: - return {"success": False, "error": "Failed to initialize purchase"} + recipient = await search_stars_recipient(client, fragment_hash, cookies, username) + req_id = await init_buy_stars(client, fragment_hash, cookies, recipient, amount) tx_data = { - 'account': account, - 'device': "iPhone15,2", - 'transaction': 1, - 'id': req_id, - 'show_sender': 0, - 'method': 'getBuyStarsLink' + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "show_sender": 1, + "method": "getBuyStarsLink", } + transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) - request_success, transaction_result = await self.api_client.execute_transaction_request( - tx_data, - account, - fragment_hash, - ) + tx_hash = await process_transaction(transaction) + return { + "success": True, + "data": { + "transaction_id": tx_hash, + "username": username, + "amount": amount, + "timestamp": int(time.time()), + }, + } - if not request_success: - return transaction_result - - success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result) - - if success: - return { - "success": True, - "data": { - "transaction_id": tx_hash, - "username": username, - "amount": amount, - "timestamp": int(time.time()) - } - } - - return {"success": False, "error": error} + 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 index 596678b..8478433 100644 --- a/app/methods/ton.py +++ b/app/methods/ton.py @@ -1,132 +1,115 @@ -import base64 +import json import logging import time import httpx -from tonutils.client import TonapiClient -from tonutils.wallet import WalletV5R1 -from app.core import config +from app.core import load_cookies +from app.core.constants import ADS_PAGE, BASE_HEADERS, DEVICE +from app.core.exceptions import FragmentError, UserNotFoundError from app.utils import ( - TransactionProcessor, - WalletLinker, - ApiClient, - clean_decode, - parse_json_response, - load_cookies, + 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, +} -class FragmentTon: - def __init__(self): - self.headers = { - "accept": "application/json, text/javascript, */*; q=0.01", - "accept-encoding": "gzip, deflate, br, zstd", - "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": "https://fragment.com", - "referer": "https://fragment.com/ads/topup", - "user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1", - "x-requested-with": "XMLHttpRequest", - } - self.cookies = load_cookies() - - self.transaction_processor = TransactionProcessor(clean_decode) - self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor) - self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker) - - @staticmethod - async def _get_account_info(): - client = TonapiClient(api_key=config.API_KEY, is_testnet=False) - wallet, pub_key, _, _ = WalletV5R1.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.hex(), - "chain": "-239", - "walletStateInit": base64.b64encode(boc).decode() - } - - async def topup_ton(self, username, amount): - if amount < 1 or not isinstance(amount, int): - return {"success": False, "error": "Amount must be an integer >= 1 TON"} - - fragment_hash = await get_fragment_hash( - self.cookies, - self.headers, - "https://fragment.com/ads/topup", +async def search_ads_recipient( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + username: str, +) -> str: + await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + data={"mode": "new", "method": "updateAdsTopupState"}, + ) + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + 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." ) - if not fragment_hash: - raise RuntimeError("Failed to fetch Fragment hash") + return recipient - account = await self._get_account_info() + +async def init_ads_topup( + client: httpx.AsyncClient, + fragment_hash: str, + cookies: dict, + recipient: str, + amount: int, +) -> str: + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=HEADERS, cookies=cookies, + 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) -> dict: + if not isinstance(amount, int) or amount < 1: + return {"success": False, "error": "Amount must be an integer >= 1 TON."} + + try: + cookies = load_cookies() + fragment_hash = await get_fragment_hash(cookies, HEADERS, ADS_PAGE) + account = await get_account_info() async with httpx.AsyncClient() as client: - update_data = {"mode": "new", "method": "updateAdsTopupState"} - await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=update_data) - - search_data = {"query": username, "method": "searchAdsTopupRecipient"} - search_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=search_data) - - search_result, error = parse_json_response(search_resp, logger, "search") - if search_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - recipient = search_result.get("found", {}).get("recipient") - if not recipient: - return {"success": False, "error": "User not found"} - - init_data = {"recipient": recipient, "amount": amount, "method": "initAdsTopupRequest"} - init_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=init_data) - - init_result, error = parse_json_response(init_resp, logger, "init") - if init_result is None: - return {"success": False, "error": f"Invalid response from Fragment API: {error}"} - - req_id = init_result.get("req_id") - if not req_id: - return {"success": False, "error": "Failed to initialize topup"} + recipient = await search_ads_recipient(client, fragment_hash, cookies, username) + req_id = await init_ads_topup(client, fragment_hash, cookies, recipient, amount) tx_data = { - 'account': account, - 'device': {"appVersion": "5.4.3", "platform": "iphone", - "features": ["SendTransaction", {"maxMessages": 255, "name": "SendTransaction"}, - {"types": ["text", "binary", "cell"], "name": "SignData"}], - "appName": "Tonkeeper", "maxProtocolVersion": 2}, - 'transaction': 1, - 'id': req_id, - 'show_sender': 1, - 'method': 'getAdsTopupLink' + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "show_sender": 1, + "method": "getAdsTopupLink", } + transaction = await execute_transaction_request(client, HEADERS, cookies, account, tx_data, fragment_hash) - request_success, transaction_result = await self.api_client.execute_transaction_request( - tx_data, - account, - fragment_hash, - ) + tx_hash = await process_transaction(transaction) + return { + "success": True, + "data": { + "transaction_id": tx_hash, + "username": username, + "amount": amount, + "timestamp": int(time.time()), + }, + } - if not request_success: - return transaction_result - - success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result) - - if success: - return { - "success": True, - "data": { - "transaction_id": tx_hash, - "username": username, - "amount": amount, - "timestamp": int(time.time()) - } - } - - return {"success": False, "error": error} + 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 index 9937bf4..6ead284 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -1,16 +1,15 @@ -from app.utils.client import ApiClient, parse_json_response -from app.utils.cookies import load_cookies +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.transaction import TransactionProcessor -from app.utils.wallet import WalletLinker +from app.utils.transaction import process_transaction +from app.utils.wallet import get_account_info, link_wallet __all__ = [ - 'TransactionProcessor', - 'WalletLinker', - 'ApiClient', 'clean_decode', + 'execute_transaction_request', + 'get_account_info', + 'get_fragment_hash', + 'link_wallet', 'parse_json_response', - 'load_cookies', - 'get_fragment_hash' + 'process_transaction', ] diff --git a/app/utils/client.py b/app/utils/client.py index 808fd9a..520a7d7 100644 --- a/app/utils/client.py +++ b/app/utils/client.py @@ -3,47 +3,42 @@ from typing import Any import httpx +from app.core.exceptions import RequestError, WalletError +from app.utils.wallet import link_wallet -def parse_json_response( - response: httpx.Response, - logger: logging.Logger, - context: str, -) -> tuple[dict[str, Any] | None, str | None]: +logger = logging.getLogger(__name__) + + +def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]: try: - return response.json(), None - except Exception as e: - logger.error(f"Failed to parse {context} response: {e}") - logger.error(f"Response content: {response.content[:200]}") - return None, str(e) + return response.json() + except Exception as exc: + raise RequestError( + f"Fragment API returned an unparseable response for '{context}': {exc}" + ) from exc -class ApiClient: - def __init__(self, headers: dict, cookies: dict, wallet_linker): - self.headers = headers - self.cookies = cookies - self.wallet_linker = wallet_linker +async def execute_transaction_request( + client: httpx.AsyncClient, + headers: dict, + cookies: 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}" - async def execute_transaction_request( - self, - tx_data: dict[str, Any], - account: dict[str, Any], - fragment_hash: str, - ) -> tuple[bool, dict[str, Any]]: - async with httpx.AsyncClient() as client: - tx_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=tx_data) - transaction, error = parse_json_response(tx_resp, logging.getLogger(__name__), "transaction") - if transaction is None: - return False, {"success": False, "error": f"Invalid response from Fragment API: {error}"} + resp = await client.post(url, headers=headers, cookies=cookies, data=tx_data) + transaction = parse_json_response(resp, tx_data.get("method", "transaction")) - if transaction.get("need_verify"): - if not await self.wallet_linker.link_wallet(account, fragment_hash): - return False, {"success": False, "error": "Failed to link wallet"} + if transaction.get("need_verify"): + if not await link_wallet(client, headers, cookies, 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, cookies=cookies, data=tx_data) + transaction = parse_json_response(resp, tx_data.get("method", "transaction")) - tx_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=tx_data) - transaction, error = parse_json_response(tx_resp, logging.getLogger(__name__), "transaction") - if transaction is None: - return False, {"success": False, "error": f"Invalid response from Fragment API: {error}"} + return transaction - return True, transaction diff --git a/app/utils/cookies.py b/app/utils/cookies.py deleted file mode 100644 index ca6c25a..0000000 --- a/app/utils/cookies.py +++ /dev/null @@ -1,35 +0,0 @@ -import json -import logging -from pathlib import Path -from typing import Any - -logger = logging.getLogger(__name__) - - -def load_cookies() -> dict[str, Any]: - cookies_path = Path(__file__).resolve().parents[2] / "cookies.json" - - if not cookies_path.exists(): - logger.error("cookies.json file not found!") - return {} - - try: - with cookies_path.open("r", encoding="utf-8") as file: - cookies = json.load(file) - - required_keys = ["stel_ssid", "stel_dt", "stel_token", "stel_ton_token"] - missing_or_empty = [ - key for key in required_keys - if not str(cookies.get(key, "")).strip() - ] - - if missing_or_empty: - logger.warning( - "cookies.json has missing or empty values: %s", - ", ".join(missing_or_empty) - ) - - return cookies - except Exception as exc: - logger.error(f"Failed to load cookies.json: {exc}") - return {} diff --git a/app/utils/decoder.py b/app/utils/decoder.py index a1093f8..1a0656a 100644 --- a/app/utils/decoder.py +++ b/app/utils/decoder.py @@ -7,34 +7,20 @@ logger = logging.getLogger(__name__) def clean_decode(payload: str) -> str: - logger.debug(f"Original payload: {payload}") + logger.debug("Original payload: %s", payload) - # Decode raw bytes first - clean = ''.join(c for c in payload if c.isalnum() or c in '+/=') - clean += '=' * (-len(clean) % 4) - raw_bytes = base64.b64decode(clean) - logger.debug(f"Raw decoded bytes: {raw_bytes}") - - # 1. Clean Base64 + # Strip non-Base64 chars and decode s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip()) s += '=' * (-len(s) % 4) + text = base64.b64decode(s).decode('utf-8', errors='ignore') - # 2. Base64 -> bytes - b = base64.b64decode(s) + # Keep only printable characters + text = ''.join(c for c in text if c in string.printable or c.isspace()) - # 3. Decode UTF-8, ignoring invalid bytes - t = b.decode('utf-8', errors='ignore') - - # 4. Remove binary characters, keep only printable + whitespace - t = ''.join(c for c in t if c in string.printable or c.isspace()) - - # 5. Extract the main text with Ref# - match = re.search(r'([0-9]*\s*Telegram .*?Ref#[A-Za-z0-9]+)', t, re.S) - if match: - result = match.group(1).strip() - else: - result = t.strip() - - logger.debug(f"Cleaned result: {result}") + # Extract "Telegram … Ref#XXXX" block + match = re.search(r'([0-9]*\s*Telegram .*?Ref#[A-Za-z0-9]+)', text, re.S) + result = match.group(1).strip() if match else text.strip() + logger.debug("Decoded result: %s", result) return result + diff --git a/app/utils/hash.py b/app/utils/hash.py index 5414ff1..8b5113c 100644 --- a/app/utils/hash.py +++ b/app/utils/hash.py @@ -4,6 +4,8 @@ from typing import Any import httpx +from app.core.exceptions import HashFetchError + logger = logging.getLogger(__name__) @@ -11,25 +13,27 @@ async def get_fragment_hash( cookies: dict[str, Any], headers: dict[str, str], page_url: str, -) -> str | None: - request_headers = { +) -> str: + page_headers = { + **headers, "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "accept-language": headers.get("accept-language") or headers.get("Accept-Language", "en-US,en;q=0.9"), - "user-agent": headers.get("user-agent") or headers.get("User-Agent", ""), "referer": "https://fragment.com/", } async with httpx.AsyncClient(cookies=cookies) as client: - response = await client.get(page_url, headers=request_headers) + response = await client.get(page_url, headers=page_headers) if response.status_code != 200: - logger.error("Failed to fetch Fragment page for hash: %s", response.status_code) - return None + raise HashFetchError( + f"Fragment returned HTTP {response.status_code} for {page_url}. " + "Check that your cookies are valid and not expired." + ) - text = response.text - match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", text) - if match: - return match.group(1) + 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." + ) - logger.error("Failed to extract Fragment hash from page") - return None + return match.group(1) diff --git a/app/utils/transaction.py b/app/utils/transaction.py index 63b4655..4df802c 100644 --- a/app/utils/transaction.py +++ b/app/utils/transaction.py @@ -1,58 +1,49 @@ import logging -from tonutils.client import TonapiClient, ToncenterV3Client +from tonutils.client import ToncenterV3Client from tonutils.wallet import WalletV5R1 -from tonutils.wallet.messages import TransferMessage from app.core import config +from app.core.exceptions import TransactionError, WalletError +from app.utils.decoder import clean_decode logger = logging.getLogger(__name__) -class TransactionProcessor: - def __init__(self, clean_decode_func): - self._clean_decode = clean_decode_func +async def process_transaction(transaction_data: dict) -> str: + 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." + ) - @staticmethod - async def _check_wallet_balance() -> tuple[bool, str | None]: - client = ToncenterV3Client(is_testnet=False, rps=1, max_retries=1) - wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) + client = ToncenterV3Client(api_key=config.API_KEY, is_testnet=False) + wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) - try: - balance = await wallet.balance() - except Exception as exc: - return False, f"Wallet balance check failed: {exc}" + # Check balance before broadcasting + try: + balance = await wallet.balance() + if float(balance) < 0.056: + raise WalletError( + f"TON wallet balance is too low: {balance} 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: - if float(balance) <= 0: - return False, "Wallet balance is zero" - except Exception: - pass + try: + message = transaction_data["transaction"]["messages"][0] + payload = clean_decode(message["payload"]) - return True, None + return await wallet.transfer( + destination=message["address"], + amount=int(message["amount"]) / 1_000_000_000, + body=payload, + ) + except (WalletError, TransactionError): + raise + except Exception as exc: + raise TransactionError(f"Transaction broadcast failed: {exc}") from exc - async def process_transaction(self, transaction_data: dict) -> tuple[bool, str | None, str | None]: - if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]: - return False, "Invalid transaction", None - - ready, reason = await self._check_wallet_balance() - if not ready: - return False, reason or "Wallet is not ready", None - - client = TonapiClient(api_key=config.API_KEY, is_testnet=False) - wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED) - - try: - message = transaction_data["transaction"]["messages"][0] - payload = self._clean_decode(message["payload"]) - - messages = [TransferMessage( - destination=message["address"], - amount=int(message["amount"]) / 1000000000, - body=payload - )] - - tx_hash = await wallet.batch_transfer_messages(messages=messages) - return True, None, tx_hash - except Exception as e: - return False, str(e), None diff --git a/app/utils/wallet.py b/app/utils/wallet.py index 7bf4b0f..6b8dbc9 100644 --- a/app/utils/wallet.py +++ b/app/utils/wallet.py @@ -1,31 +1,62 @@ +import base64 +import json +import logging from typing import Any import httpx +from tonutils.client import ToncenterV3Client +from tonutils.wallet import WalletV5R1 + +from app.core import config +from app.core.constants import DEVICE +from app.core.exceptions import TransactionError, WalletError +from app.utils.transaction import process_transaction + +logger = logging.getLogger(__name__) -class WalletLinker: - def __init__(self, headers: dict, cookies: dict, transaction_processor): - self.headers = headers - self.cookies = cookies - self.transaction_processor = transaction_processor +async def get_account_info() -> dict[str, Any]: + try: + client = ToncenterV3Client(api_key=config.API_KEY, is_testnet=False) + wallet, pub_key, _, _ = WalletV5R1.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.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(self, account: dict[str, Any], fragment_hash: str) -> bool: - async with httpx.AsyncClient() as client: - data = { - 'account': account, - 'device': "iPhone15,2", - 'method': 'linkWallet' - } - response = await client.post(f"https://fragment.com/api?hash={fragment_hash}", - headers=self.headers, cookies=self.cookies, data=data) - result = response.json() +async def link_wallet( + client: httpx.AsyncClient, + headers: dict, + cookies: dict, + account: dict[str, Any], + fragment_hash: str, +) -> bool: + resp = await client.post( + f"https://fragment.com/api?hash={fragment_hash}", + headers=headers, + cookies=cookies, + data={ + "account": json.dumps(account), + "device": DEVICE, + "method": "linkWallet", + }, + ) + result = resp.json() - if result.get("ok"): - return True - - if "transaction" in result: - success, _, _ = await self.transaction_processor.process_transaction(result) - return success + 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/main.py b/main.py index e3ca22d..4977941 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,8 @@ import asyncio import logging -from app.methods import FragmentPremium, FragmentStars, FragmentTon from app.core import setup_logging +from app.methods import buy_premium, buy_stars, topup_ton logger = logging.getLogger(__name__) @@ -10,9 +10,8 @@ logger = logging.getLogger(__name__) async def topup_ton_example(): logger.info("Starting TON topup example") - ton_client = FragmentTon() - # @bohd4nx - target username, 5 - TON amount (integer 1-1000000000 (one billion)) - result = await ton_client.topup_ton("@bohd4nx", 100) + # @bohd4nx - target username, 100 - TON amount (integer 1-1000000000 (one billion)) + result = await topup_ton("@bohd4nx", 100) if result["success"]: data = result["data"] @@ -25,9 +24,8 @@ async def topup_ton_example(): async def buy_premium_example(): logger.info("Starting Premium purchase example") - premium_client = FragmentPremium() - # @bohd4nx - target username, 6 - months duration (3, 6, or 12 only) - result = await premium_client.buy_premium("@bohd4nx", 12) + # @bohd4nx - target username, 12 - months duration (3, 6, or 12 only) + result = await buy_premium("@bohd4nx", 12) if result["success"]: data = result["data"] @@ -40,9 +38,8 @@ async def buy_premium_example(): async def buy_stars_example(): logger.info("Starting Stars purchase example") - stars_client = FragmentStars() - # @bohd4nx - target username, 50 - stars amount (integer 50-1000000 (one million)) - result = await stars_client.buy_stars("@bohd4nx", 1000000) + # @bohd4nx - target username, 1000000 - stars amount (integer 50-1000000 (one million)) + result = await buy_stars("@bohd4nx", 1000000) if result["success"]: data = result["data"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fc02a18 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["[0-9][0-9][0-9]_test_*.py"] +asyncio_mode = "auto" +addopts = "-v --tb=short" + +[tool.ruff] +target-version = "py312" +line-length = 100 +src = ["app", "tests"] + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "UP", "B", "C4", "RUF"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["app"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7700cd3..089c06d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-dotenv==1.2.1 +python-dotenv==1.2.2 asyncio==4.0.0 httpx==0.28.1 -tonutils==0.5.6 +tonutils==2.0.0