mirror of
https://github.com/vibe-existing/pyfragment.git
synced 2026-07-25 06:54:31 +00:00
refactor: migrate to tonutils v2, pytest, pyproject.toml
- tonutils 2.0.0: TonapiClient → ToncenterV3Client, wallet.transfer() - classes → plain async functions across all utils and methods - core/: constants.py, cookies.py, exceptions.py extracted - DEVICE constant in constants.py (single source of truth) - account/device serialised with json.dumps() in all tx payloads - tests: unittest → pytest, conftest.py fixtures, 001/002 naming - pyproject.toml: pytest + ruff config - min balance check: 0.056 TON
This commit is contained in:
+1
-3
@@ -1,7 +1,5 @@
|
|||||||
# Fragment.com cookies - copy from browser after login (Header String format)
|
# Fragment.com cookies - copy from browser after login (Header String format)
|
||||||
# How to get fragment hash: open devtools -> Network -> api?hash=<your_fragment_hash_here>
|
# Hash is now fetched dynamically
|
||||||
# HASH = "your_fragment_hash_here" --- IGNORE ---
|
|
||||||
# Hash is now fetched dynamically, so this line is no longer needed.
|
|
||||||
|
|
||||||
# TON wallet seed phrase - 12 or 24 words separated by spaces
|
# TON wallet seed phrase - 12 or 24 words separated by spaces
|
||||||
SEED = "your_ton_wallet_seed_phrase_here"
|
SEED = "your_ton_wallet_seed_phrase_here"
|
||||||
|
|||||||
+1
-1
@@ -26,4 +26,4 @@ tests/
|
|||||||
|
|
||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
+32
-2
@@ -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
|
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",
|
||||||
|
]
|
||||||
|
|||||||
+23
-19
@@ -5,34 +5,38 @@ from pathlib import Path
|
|||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from app.core.exceptions import ConfigError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
def __init__(self):
|
SEED: str
|
||||||
|
API_KEY: str
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
env_path = Path(__file__).resolve().parents[2] / ".env"
|
env_path = Path(__file__).resolve().parents[2] / ".env"
|
||||||
|
|
||||||
if not env_path.exists():
|
if not env_path.exists():
|
||||||
logger.error(".env file not found!")
|
raise ConfigError(
|
||||||
sys.exit(1)
|
".env file not found. "
|
||||||
|
"Copy .env.example to .env and fill in SEED and API_KEY."
|
||||||
|
)
|
||||||
|
|
||||||
load_dotenv(env_path)
|
load_dotenv(env_path)
|
||||||
|
|
||||||
required_keys = ["SEED", "API_KEY"]
|
missing = [k for k in ("SEED", "API_KEY") if not os.getenv(k, "").strip()]
|
||||||
missing_keys: list[str] = []
|
if missing:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Missing required environment variables: {', '.join(missing)}. "
|
||||||
|
"Open .env and fill in all required fields."
|
||||||
|
)
|
||||||
|
|
||||||
for key in required_keys:
|
self.SEED = os.getenv("SEED", "").strip()
|
||||||
value = os.getenv(key, "").strip()
|
self.API_KEY = os.getenv("API_KEY", "").strip()
|
||||||
if not value:
|
|
||||||
missing_keys.append(key)
|
|
||||||
setattr(self, key, value)
|
|
||||||
|
|
||||||
if missing_keys:
|
try:
|
||||||
logger.error(f"Missing required environment variables: {', '.join(missing_keys)}")
|
config = Config()
|
||||||
logger.error("Create .env file based on .env.example and fill all fields")
|
except ConfigError as e:
|
||||||
sys.exit(1)
|
logger.error("Configuration error: %s", e)
|
||||||
|
sys.exit(1)
|
||||||
logger.info("Configuration loaded successfully")
|
|
||||||
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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."""
|
||||||
+1
-5
@@ -1,7 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.__meta__ import APP_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging() -> None:
|
def setup_logging() -> None:
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter(
|
||||||
@@ -14,7 +12,7 @@ def setup_logging() -> None:
|
|||||||
console_handler.setFormatter(formatter)
|
console_handler.setFormatter(formatter)
|
||||||
|
|
||||||
file_handler = logging.FileHandler(
|
file_handler = logging.FileHandler(
|
||||||
f"{APP_NAME}.log",
|
"FragmentAPI.log",
|
||||||
mode="w",
|
mode="w",
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
@@ -26,8 +24,6 @@ def setup_logging() -> None:
|
|||||||
force=True
|
force=True
|
||||||
)
|
)
|
||||||
|
|
||||||
logging.getLogger("aiogram.dispatcher").setLevel(logging.INFO)
|
|
||||||
logging.getLogger("aiogram.event").setLevel(logging.ERROR)
|
|
||||||
logging.getLogger("httpx").setLevel(logging.INFO)
|
logging.getLogger("httpx").setLevel(logging.INFO)
|
||||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from app.methods.premium import FragmentPremium
|
from app.methods.premium import buy_premium
|
||||||
from app.methods.stars import FragmentStars
|
from app.methods.stars import buy_stars
|
||||||
from app.methods.ton import FragmentTon
|
from app.methods.ton import topup_ton
|
||||||
|
|
||||||
__all__ = ['FragmentTon', 'FragmentPremium', 'FragmentStars']
|
__all__ = ['buy_premium', 'buy_stars', 'topup_ton']
|
||||||
|
|||||||
+94
-111
@@ -1,133 +1,116 @@
|
|||||||
import base64
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
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 (
|
from app.utils import (
|
||||||
TransactionProcessor,
|
execute_transaction_request,
|
||||||
WalletLinker,
|
get_account_info,
|
||||||
ApiClient,
|
|
||||||
clean_decode,
|
|
||||||
parse_json_response,
|
|
||||||
load_cookies,
|
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
|
parse_json_response,
|
||||||
|
process_transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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()
|
async def search_premium_recipient(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
self.transaction_processor = TransactionProcessor(clean_decode)
|
fragment_hash: str,
|
||||||
self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor)
|
cookies: dict,
|
||||||
self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker)
|
username: str,
|
||||||
|
months: int,
|
||||||
@staticmethod
|
) -> str:
|
||||||
async def _get_account_info():
|
resp = await client.post(
|
||||||
client = TonapiClient(api_key=config.API_KEY, is_testnet=False)
|
f"https://fragment.com/api?hash={fragment_hash}",
|
||||||
wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
|
headers=HEADERS, cookies=cookies,
|
||||||
boc = wallet.state_init.serialize().to_boc()
|
data={"query": username, "months": months, "method": "searchPremiumGiftRecipient"},
|
||||||
|
)
|
||||||
return {
|
result = parse_json_response(resp, "searchPremiumGiftRecipient")
|
||||||
"address": wallet.address.to_str(False, False),
|
recipient = result.get("found", {}).get("recipient")
|
||||||
"publicKey": pub_key.hex(),
|
if not recipient:
|
||||||
"chain": "-239",
|
raise UserNotFoundError(
|
||||||
"walletStateInit": base64.b64encode(boc).decode()
|
f"Telegram user '{username}' was not found on Fragment. "
|
||||||
}
|
"Make sure the username is correct and the account exists."
|
||||||
|
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
if not fragment_hash:
|
return recipient
|
||||||
raise RuntimeError("Failed to fetch Fragment hash")
|
|
||||||
|
|
||||||
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:
|
async with httpx.AsyncClient() as client:
|
||||||
search_data = {"query": username, "months": months, "method": "searchPremiumGiftRecipient"}
|
recipient = await search_premium_recipient(client, fragment_hash, cookies, username, months)
|
||||||
search_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}",
|
req_id = await init_gift_premium(client, fragment_hash, cookies, recipient, months)
|
||||||
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"}
|
|
||||||
|
|
||||||
tx_data = {
|
tx_data = {
|
||||||
'account': account,
|
"account": json.dumps(account),
|
||||||
'device': {"appVersion": "5.4.3", "platform": "iphone",
|
"device": DEVICE,
|
||||||
"features": ["SendTransaction", {"maxMessages": 255, "name": "SendTransaction"},
|
"transaction": 1,
|
||||||
{"types": ["text", "binary", "cell"], "name": "SignData"}],
|
"id": req_id,
|
||||||
"appName": "Tonkeeper", "maxProtocolVersion": 2},
|
"show_sender": 1,
|
||||||
'transaction': 1,
|
"method": "getGiftPremiumLink",
|
||||||
'id': req_id,
|
|
||||||
'show_sender': 1,
|
|
||||||
'ref': "OprzztcdJ",
|
|
||||||
'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_hash = await process_transaction(transaction)
|
||||||
tx_data,
|
return {
|
||||||
account,
|
"success": True,
|
||||||
fragment_hash,
|
"data": {
|
||||||
)
|
"transaction_id": tx_hash,
|
||||||
|
"username": username,
|
||||||
|
"months": months,
|
||||||
|
"timestamp": int(time.time()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if not request_success:
|
except FragmentError as exc:
|
||||||
return transaction_result
|
logger.error("Premium purchase failed — %s", exc)
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result)
|
except Exception as exc:
|
||||||
|
logger.exception("Unexpected error during Premium purchase")
|
||||||
if success:
|
return {"success": False, "error": f"Unexpected error: {exc}"}
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"transaction_id": tx_hash,
|
|
||||||
"username": username,
|
|
||||||
"months": months,
|
|
||||||
"timestamp": int(time.time())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"success": False, "error": error}
|
|
||||||
|
|||||||
+88
-103
@@ -1,125 +1,110 @@
|
|||||||
import base64
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
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 (
|
from app.utils import (
|
||||||
TransactionProcessor,
|
execute_transaction_request,
|
||||||
WalletLinker,
|
get_account_info,
|
||||||
ApiClient,
|
|
||||||
clean_decode,
|
|
||||||
parse_json_response,
|
|
||||||
load_cookies,
|
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
|
parse_json_response,
|
||||||
|
process_transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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()
|
async def search_stars_recipient(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
self.transaction_processor = TransactionProcessor(clean_decode)
|
fragment_hash: str,
|
||||||
self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor)
|
cookies: dict,
|
||||||
self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker)
|
username: str,
|
||||||
|
) -> str:
|
||||||
@staticmethod
|
resp = await client.post(
|
||||||
async def _get_account_info():
|
f"https://fragment.com/api?hash={fragment_hash}",
|
||||||
client = TonapiClient(api_key=config.API_KEY, is_testnet=False)
|
headers=HEADERS, cookies=cookies,
|
||||||
wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
|
data={"query": username, "quantity": "", "method": "searchStarsRecipient"},
|
||||||
boc = wallet.state_init.serialize().to_boc()
|
)
|
||||||
|
result = parse_json_response(resp, "searchStarsRecipient")
|
||||||
return {
|
recipient = result.get("found", {}).get("recipient")
|
||||||
"address": wallet.address.to_str(False, False),
|
if not recipient:
|
||||||
"publicKey": pub_key.hex(),
|
raise UserNotFoundError(
|
||||||
"chain": "-239",
|
f"Telegram user '{username}' was not found on Fragment. "
|
||||||
"walletStateInit": base64.b64encode(boc).decode()
|
"Make sure the username is correct and the account exists."
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
if not fragment_hash:
|
return recipient
|
||||||
raise RuntimeError("Failed to fetch Fragment hash")
|
|
||||||
|
|
||||||
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:
|
async with httpx.AsyncClient() as client:
|
||||||
search_data = {"query": username, "quantity": "", "method": "searchStarsRecipient"}
|
recipient = await search_stars_recipient(client, fragment_hash, cookies, username)
|
||||||
search_resp = await client.post(f"https://fragment.com/api?hash={fragment_hash}",
|
req_id = await init_buy_stars(client, fragment_hash, cookies, recipient, amount)
|
||||||
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"}
|
|
||||||
|
|
||||||
tx_data = {
|
tx_data = {
|
||||||
'account': account,
|
"account": json.dumps(account),
|
||||||
'device': "iPhone15,2",
|
"device": DEVICE,
|
||||||
'transaction': 1,
|
"transaction": 1,
|
||||||
'id': req_id,
|
"id": req_id,
|
||||||
'show_sender': 0,
|
"show_sender": 1,
|
||||||
'method': 'getBuyStarsLink'
|
"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_hash = await process_transaction(transaction)
|
||||||
tx_data,
|
return {
|
||||||
account,
|
"success": True,
|
||||||
fragment_hash,
|
"data": {
|
||||||
)
|
"transaction_id": tx_hash,
|
||||||
|
"username": username,
|
||||||
|
"amount": amount,
|
||||||
|
"timestamp": int(time.time()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if not request_success:
|
except FragmentError as exc:
|
||||||
return transaction_result
|
logger.error("Stars purchase failed — %s", exc)
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result)
|
except Exception as exc:
|
||||||
|
logger.exception("Unexpected error during Stars purchase")
|
||||||
if success:
|
return {"success": False, "error": f"Unexpected error: {exc}"}
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"transaction_id": tx_hash,
|
|
||||||
"username": username,
|
|
||||||
"amount": amount,
|
|
||||||
"timestamp": int(time.time())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"success": False, "error": error}
|
|
||||||
|
|||||||
+93
-110
@@ -1,132 +1,115 @@
|
|||||||
import base64
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
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 (
|
from app.utils import (
|
||||||
TransactionProcessor,
|
execute_transaction_request,
|
||||||
WalletLinker,
|
get_account_info,
|
||||||
ApiClient,
|
|
||||||
clean_decode,
|
|
||||||
parse_json_response,
|
|
||||||
load_cookies,
|
|
||||||
get_fragment_hash,
|
get_fragment_hash,
|
||||||
|
parse_json_response,
|
||||||
|
process_transaction,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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()
|
async def search_ads_recipient(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
self.transaction_processor = TransactionProcessor(clean_decode)
|
fragment_hash: str,
|
||||||
self.wallet_linker = WalletLinker(self.headers, self.cookies, self.transaction_processor)
|
cookies: dict,
|
||||||
self.api_client = ApiClient(self.headers, self.cookies, self.wallet_linker)
|
username: str,
|
||||||
|
) -> str:
|
||||||
@staticmethod
|
await client.post(
|
||||||
async def _get_account_info():
|
f"https://fragment.com/api?hash={fragment_hash}",
|
||||||
client = TonapiClient(api_key=config.API_KEY, is_testnet=False)
|
headers=HEADERS, cookies=cookies,
|
||||||
wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
|
data={"mode": "new", "method": "updateAdsTopupState"},
|
||||||
boc = wallet.state_init.serialize().to_boc()
|
)
|
||||||
|
resp = await client.post(
|
||||||
return {
|
f"https://fragment.com/api?hash={fragment_hash}",
|
||||||
"address": wallet.address.to_str(False, False),
|
headers=HEADERS, cookies=cookies,
|
||||||
"publicKey": pub_key.hex(),
|
data={"query": username, "method": "searchAdsTopupRecipient"},
|
||||||
"chain": "-239",
|
)
|
||||||
"walletStateInit": base64.b64encode(boc).decode()
|
result = parse_json_response(resp, "searchAdsTopupRecipient")
|
||||||
}
|
recipient = result.get("found", {}).get("recipient")
|
||||||
|
if not recipient:
|
||||||
async def topup_ton(self, username, amount):
|
raise UserNotFoundError(
|
||||||
if amount < 1 or not isinstance(amount, int):
|
f"Telegram user '{username}' was not found on Fragment. "
|
||||||
return {"success": False, "error": "Amount must be an integer >= 1 TON"}
|
"Make sure the username is correct and the account exists."
|
||||||
|
|
||||||
fragment_hash = await get_fragment_hash(
|
|
||||||
self.cookies,
|
|
||||||
self.headers,
|
|
||||||
"https://fragment.com/ads/topup",
|
|
||||||
)
|
)
|
||||||
if not fragment_hash:
|
return recipient
|
||||||
raise RuntimeError("Failed to fetch Fragment hash")
|
|
||||||
|
|
||||||
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:
|
async with httpx.AsyncClient() as client:
|
||||||
update_data = {"mode": "new", "method": "updateAdsTopupState"}
|
recipient = await search_ads_recipient(client, fragment_hash, cookies, username)
|
||||||
await client.post(f"https://fragment.com/api?hash={fragment_hash}",
|
req_id = await init_ads_topup(client, fragment_hash, cookies, recipient, amount)
|
||||||
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"}
|
|
||||||
|
|
||||||
tx_data = {
|
tx_data = {
|
||||||
'account': account,
|
"account": json.dumps(account),
|
||||||
'device': {"appVersion": "5.4.3", "platform": "iphone",
|
"device": DEVICE,
|
||||||
"features": ["SendTransaction", {"maxMessages": 255, "name": "SendTransaction"},
|
"transaction": 1,
|
||||||
{"types": ["text", "binary", "cell"], "name": "SignData"}],
|
"id": req_id,
|
||||||
"appName": "Tonkeeper", "maxProtocolVersion": 2},
|
"show_sender": 1,
|
||||||
'transaction': 1,
|
"method": "getAdsTopupLink",
|
||||||
'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_hash = await process_transaction(transaction)
|
||||||
tx_data,
|
return {
|
||||||
account,
|
"success": True,
|
||||||
fragment_hash,
|
"data": {
|
||||||
)
|
"transaction_id": tx_hash,
|
||||||
|
"username": username,
|
||||||
|
"amount": amount,
|
||||||
|
"timestamp": int(time.time()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
if not request_success:
|
except FragmentError as exc:
|
||||||
return transaction_result
|
logger.error("TON topup failed — %s", exc)
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
success, error, tx_hash = await self.transaction_processor.process_transaction(transaction_result)
|
except Exception as exc:
|
||||||
|
logger.exception("Unexpected error during TON topup")
|
||||||
if success:
|
return {"success": False, "error": f"Unexpected error: {exc}"}
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"data": {
|
|
||||||
"transaction_id": tx_hash,
|
|
||||||
"username": username,
|
|
||||||
"amount": amount,
|
|
||||||
"timestamp": int(time.time())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {"success": False, "error": error}
|
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
from app.utils.client import ApiClient, parse_json_response
|
from app.utils.client import execute_transaction_request, parse_json_response
|
||||||
from app.utils.cookies import load_cookies
|
|
||||||
from app.utils.decoder import clean_decode
|
from app.utils.decoder import clean_decode
|
||||||
from app.utils.hash import get_fragment_hash
|
from app.utils.hash import get_fragment_hash
|
||||||
from app.utils.transaction import TransactionProcessor
|
from app.utils.transaction import process_transaction
|
||||||
from app.utils.wallet import WalletLinker
|
from app.utils.wallet import get_account_info, link_wallet
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'TransactionProcessor',
|
|
||||||
'WalletLinker',
|
|
||||||
'ApiClient',
|
|
||||||
'clean_decode',
|
'clean_decode',
|
||||||
|
'execute_transaction_request',
|
||||||
|
'get_account_info',
|
||||||
|
'get_fragment_hash',
|
||||||
|
'link_wallet',
|
||||||
'parse_json_response',
|
'parse_json_response',
|
||||||
'load_cookies',
|
'process_transaction',
|
||||||
'get_fragment_hash'
|
|
||||||
]
|
]
|
||||||
|
|||||||
+31
-36
@@ -3,47 +3,42 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from app.core.exceptions import RequestError, WalletError
|
||||||
|
from app.utils.wallet import link_wallet
|
||||||
|
|
||||||
def parse_json_response(
|
logger = logging.getLogger(__name__)
|
||||||
response: httpx.Response,
|
|
||||||
logger: logging.Logger,
|
|
||||||
context: str,
|
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
|
||||||
) -> tuple[dict[str, Any] | None, str | None]:
|
|
||||||
try:
|
try:
|
||||||
return response.json(), None
|
return response.json()
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
logger.error(f"Failed to parse {context} response: {e}")
|
raise RequestError(
|
||||||
logger.error(f"Response content: {response.content[:200]}")
|
f"Fragment API returned an unparseable response for '{context}': {exc}"
|
||||||
return None, str(e)
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
class ApiClient:
|
async def execute_transaction_request(
|
||||||
def __init__(self, headers: dict, cookies: dict, wallet_linker):
|
client: httpx.AsyncClient,
|
||||||
self.headers = headers
|
headers: dict,
|
||||||
self.cookies = cookies
|
cookies: dict,
|
||||||
self.wallet_linker = wallet_linker
|
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(
|
resp = await client.post(url, headers=headers, cookies=cookies, data=tx_data)
|
||||||
self,
|
transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
|
||||||
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}"}
|
|
||||||
|
|
||||||
if transaction.get("need_verify"):
|
if transaction.get("need_verify"):
|
||||||
if not await self.wallet_linker.link_wallet(account, fragment_hash):
|
if not await link_wallet(client, headers, cookies, account, fragment_hash):
|
||||||
return False, {"success": False, "error": "Failed to link wallet"}
|
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}",
|
return transaction
|
||||||
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 True, transaction
|
|
||||||
|
|||||||
@@ -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 {}
|
|
||||||
+10
-24
@@ -7,34 +7,20 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def clean_decode(payload: str) -> str:
|
def clean_decode(payload: str) -> str:
|
||||||
logger.debug(f"Original payload: {payload}")
|
logger.debug("Original payload: %s", payload)
|
||||||
|
|
||||||
# Decode raw bytes first
|
# Strip non-Base64 chars and decode
|
||||||
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
|
|
||||||
s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip())
|
s = re.sub(r'[^A-Za-z0-9+/=]', '', payload.strip())
|
||||||
s += '=' * (-len(s) % 4)
|
s += '=' * (-len(s) % 4)
|
||||||
|
text = base64.b64decode(s).decode('utf-8', errors='ignore')
|
||||||
|
|
||||||
# 2. Base64 -> bytes
|
# Keep only printable characters
|
||||||
b = base64.b64decode(s)
|
text = ''.join(c for c in text if c in string.printable or c.isspace())
|
||||||
|
|
||||||
# 3. Decode UTF-8, ignoring invalid bytes
|
# Extract "Telegram … Ref#XXXX" block
|
||||||
t = b.decode('utf-8', errors='ignore')
|
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()
|
||||||
# 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}")
|
|
||||||
|
|
||||||
|
logger.debug("Decoded result: %s", result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
+17
-13
@@ -4,6 +4,8 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from app.core.exceptions import HashFetchError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -11,25 +13,27 @@ async def get_fragment_hash(
|
|||||||
cookies: dict[str, Any],
|
cookies: dict[str, Any],
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
page_url: str,
|
page_url: str,
|
||||||
) -> str | None:
|
) -> str:
|
||||||
request_headers = {
|
page_headers = {
|
||||||
|
**headers,
|
||||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
"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/",
|
"referer": "https://fragment.com/",
|
||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(cookies=cookies) as client:
|
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:
|
if response.status_code != 200:
|
||||||
logger.error("Failed to fetch Fragment page for hash: %s", response.status_code)
|
raise HashFetchError(
|
||||||
return None
|
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]+)", response.text)
|
||||||
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", text)
|
if not match:
|
||||||
if match:
|
raise HashFetchError(
|
||||||
return match.group(1)
|
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 match.group(1)
|
||||||
return None
|
|
||||||
|
|||||||
+35
-44
@@ -1,58 +1,49 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from tonutils.client import TonapiClient, ToncenterV3Client
|
from tonutils.client import ToncenterV3Client
|
||||||
from tonutils.wallet import WalletV5R1
|
from tonutils.wallet import WalletV5R1
|
||||||
from tonutils.wallet.messages import TransferMessage
|
|
||||||
|
|
||||||
from app.core import config
|
from app.core import config
|
||||||
|
from app.core.exceptions import TransactionError, WalletError
|
||||||
|
from app.utils.decoder import clean_decode
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TransactionProcessor:
|
async def process_transaction(transaction_data: dict) -> str:
|
||||||
def __init__(self, clean_decode_func):
|
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
|
||||||
self._clean_decode = clean_decode_func
|
raise TransactionError(
|
||||||
|
"Fragment returned an invalid transaction payload. "
|
||||||
|
"The API response is missing expected 'transaction.messages' data."
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
client = ToncenterV3Client(api_key=config.API_KEY, is_testnet=False)
|
||||||
async def _check_wallet_balance() -> tuple[bool, str | None]:
|
wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
|
||||||
client = ToncenterV3Client(is_testnet=False, rps=1, max_retries=1)
|
|
||||||
wallet, _, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
|
|
||||||
|
|
||||||
try:
|
# Check balance before broadcasting
|
||||||
balance = await wallet.balance()
|
try:
|
||||||
except Exception as exc:
|
balance = await wallet.balance()
|
||||||
return False, f"Wallet balance check failed: {exc}"
|
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:
|
try:
|
||||||
if float(balance) <= 0:
|
message = transaction_data["transaction"]["messages"][0]
|
||||||
return False, "Wallet balance is zero"
|
payload = clean_decode(message["payload"])
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
+52
-21
@@ -1,31 +1,62 @@
|
|||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
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:
|
async def get_account_info() -> dict[str, Any]:
|
||||||
def __init__(self, headers: dict, cookies: dict, transaction_processor):
|
try:
|
||||||
self.headers = headers
|
client = ToncenterV3Client(api_key=config.API_KEY, is_testnet=False)
|
||||||
self.cookies = cookies
|
wallet, pub_key, _, _ = WalletV5R1.from_mnemonic(client=client, mnemonic=config.SEED)
|
||||||
self.transaction_processor = transaction_processor
|
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}",
|
async def link_wallet(
|
||||||
headers=self.headers, cookies=self.cookies, data=data)
|
client: httpx.AsyncClient,
|
||||||
result = response.json()
|
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"):
|
if result.get("ok"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if "transaction" in result:
|
|
||||||
success, _, _ = await self.transaction_processor.process_transaction(result)
|
|
||||||
return success
|
|
||||||
|
|
||||||
|
if "transaction" in result:
|
||||||
|
try:
|
||||||
|
await process_transaction(result)
|
||||||
|
return True
|
||||||
|
except (TransactionError, WalletError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.methods import FragmentPremium, FragmentStars, FragmentTon
|
|
||||||
from app.core import setup_logging
|
from app.core import setup_logging
|
||||||
|
from app.methods import buy_premium, buy_stars, topup_ton
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -10,9 +10,8 @@ logger = logging.getLogger(__name__)
|
|||||||
async def topup_ton_example():
|
async def topup_ton_example():
|
||||||
logger.info("Starting TON topup example")
|
logger.info("Starting TON topup example")
|
||||||
|
|
||||||
ton_client = FragmentTon()
|
# @bohd4nx - target username, 100 - TON amount (integer 1-1000000000 (one billion))
|
||||||
# @bohd4nx - target username, 5 - TON amount (integer 1-1000000000 (one billion))
|
result = await topup_ton("@bohd4nx", 100)
|
||||||
result = await ton_client.topup_ton("@bohd4nx", 100)
|
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
data = result["data"]
|
data = result["data"]
|
||||||
@@ -25,9 +24,8 @@ async def topup_ton_example():
|
|||||||
async def buy_premium_example():
|
async def buy_premium_example():
|
||||||
logger.info("Starting Premium purchase example")
|
logger.info("Starting Premium purchase example")
|
||||||
|
|
||||||
premium_client = FragmentPremium()
|
# @bohd4nx - target username, 12 - months duration (3, 6, or 12 only)
|
||||||
# @bohd4nx - target username, 6 - months duration (3, 6, or 12 only)
|
result = await buy_premium("@bohd4nx", 12)
|
||||||
result = await premium_client.buy_premium("@bohd4nx", 12)
|
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
data = result["data"]
|
data = result["data"]
|
||||||
@@ -40,9 +38,8 @@ async def buy_premium_example():
|
|||||||
async def buy_stars_example():
|
async def buy_stars_example():
|
||||||
logger.info("Starting Stars purchase example")
|
logger.info("Starting Stars purchase example")
|
||||||
|
|
||||||
stars_client = FragmentStars()
|
# @bohd4nx - target username, 1000000 - stars amount (integer 50-1000000 (one million))
|
||||||
# @bohd4nx - target username, 50 - stars amount (integer 50-1000000 (one million))
|
result = await buy_stars("@bohd4nx", 1000000)
|
||||||
result = await stars_client.buy_stars("@bohd4nx", 1000000)
|
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
data = result["data"]
|
data = result["data"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
python-dotenv==1.2.1
|
python-dotenv==1.2.2
|
||||||
asyncio==4.0.0
|
asyncio==4.0.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
tonutils==0.5.6
|
tonutils==2.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user