mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-28 07:41:41 +00:00
refactor: restructure as installable PyPI package
- Rename app/ → fragmentapi/ for proper package naming - Add FragmentClient class with gift_premium, gift_stars, topup_ton methods - Restructure core/ → types/ (exceptions, results, constants) - Merge utils/hash.py into utils/client.py - Replace _version.py with importlib.metadata - Add input validation with min/max bounds - Add unit tests: decode, client init/cookies - Rename 002_test_hash → 003_test_hash - Clean up pyproject.toml: pin deps, production classifiers
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
from fragmentapi.utils.client import (
|
||||
execute_transaction_request,
|
||||
get_fragment_hash,
|
||||
parse_json_response,
|
||||
)
|
||||
from fragmentapi.utils.decoder import clean_decode
|
||||
from fragmentapi.utils.wallet import get_account_info, process_transaction
|
||||
|
||||
__all__ = [
|
||||
"clean_decode",
|
||||
"execute_transaction_request",
|
||||
"get_account_info",
|
||||
"get_fragment_hash",
|
||||
"parse_json_response",
|
||||
"process_transaction",
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from fragmentapi.types import HashFetchError, RequestError, VerificationError
|
||||
|
||||
|
||||
async def get_fragment_hash(
|
||||
cookies: dict[str, Any],
|
||||
headers: dict[str, str],
|
||||
page_url: str,
|
||||
) -> str:
|
||||
"""Fetch the API hash from a Fragment page.
|
||||
|
||||
Fragment embeds a short-lived hash in each page's HTML that must be
|
||||
included in every subsequent API request. This function loads the page
|
||||
as a real browser navigation (not XHR) so Fragment returns full HTML.
|
||||
|
||||
Args:
|
||||
cookies: Active Fragment session cookies.
|
||||
headers: Base headers for the relevant Fragment page.
|
||||
page_url: URL of the Fragment page to fetch the hash from.
|
||||
|
||||
Returns:
|
||||
Lowercase hex hash string.
|
||||
|
||||
Raises:
|
||||
HashFetchError: If the page returns a non-200 status or the hash
|
||||
is not found in the response HTML.
|
||||
"""
|
||||
page_headers = {
|
||||
k: v
|
||||
for k, v in headers.items()
|
||||
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
|
||||
}
|
||||
page_headers.update(
|
||||
{
|
||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"referer": "https://fragment.com/",
|
||||
"sec-fetch-dest": "document",
|
||||
"sec-fetch-mode": "navigate",
|
||||
"upgrade-insecure-requests": "1",
|
||||
}
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(cookies=cookies) as session:
|
||||
response = await session.get(page_url, headers=page_headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise HashFetchError(HashFetchError.BAD_STATUS.format(status=response.status_code, url=page_url))
|
||||
|
||||
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
|
||||
if not match:
|
||||
raise HashFetchError(HashFetchError.NOT_FOUND.format(url=page_url))
|
||||
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
|
||||
"""Parse a Fragment API JSON response.
|
||||
|
||||
Args:
|
||||
response: The HTTP response object.
|
||||
context: Human-readable name of the API method, used in error messages.
|
||||
|
||||
Returns:
|
||||
Parsed response as a dict.
|
||||
|
||||
Raises:
|
||||
RequestError: If the response body cannot be decoded as JSON.
|
||||
"""
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as exc:
|
||||
raise RequestError(RequestError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
||||
|
||||
|
||||
async def execute_transaction_request(
|
||||
session: httpx.AsyncClient,
|
||||
headers: dict,
|
||||
tx_data: dict[str, Any],
|
||||
fragment_hash: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Post a transaction request to the Fragment API.
|
||||
|
||||
Args:
|
||||
session: Active httpx session with Fragment cookies.
|
||||
headers: Page-specific HTTP headers.
|
||||
tx_data: Form data payload for the API method.
|
||||
fragment_hash: Short-lived hash from the Fragment page.
|
||||
|
||||
Returns:
|
||||
Parsed API response dict containing transaction data.
|
||||
|
||||
Raises:
|
||||
VerificationError: If Fragment requires KYC verification.
|
||||
RequestError: If the response cannot be parsed.
|
||||
"""
|
||||
url = f"https://fragment.com/api?hash={fragment_hash}"
|
||||
resp = await session.post(url, headers=headers, data=tx_data)
|
||||
transaction = parse_json_response(resp, tx_data.get("method", "transaction"))
|
||||
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
return transaction
|
||||
@@ -0,0 +1,20 @@
|
||||
import base64
|
||||
|
||||
from pytoniq_core import Cell
|
||||
|
||||
from fragmentapi.types import RequestError
|
||||
|
||||
|
||||
def clean_decode(payload: str) -> str:
|
||||
s = payload.strip()
|
||||
if not s:
|
||||
return ""
|
||||
s += "=" * (-len(s) % 4)
|
||||
try:
|
||||
boc = base64.b64decode(s)
|
||||
cell = Cell.one_from_boc(boc)
|
||||
sl = cell.begin_parse()
|
||||
sl.load_uint(32) # op code — always 0 for text comment
|
||||
return sl.load_snake_string().strip()
|
||||
except Exception as exc:
|
||||
raise RequestError(RequestError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
|
||||
@@ -0,0 +1,69 @@
|
||||
import base64
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from tonutils.clients import TonapiClient
|
||||
from tonutils.types import NetworkGlobalID
|
||||
|
||||
from fragmentapi.types import WALLET_CLASSES, TransactionError, WalletError
|
||||
from fragmentapi.utils.decoder import clean_decode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fragmentapi.client import FragmentClient
|
||||
|
||||
|
||||
def _init_ton_client(client: "FragmentClient") -> TonapiClient:
|
||||
return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key)
|
||||
|
||||
|
||||
async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str:
|
||||
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
|
||||
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
||||
|
||||
# TODO: Investigate 406 'inbound external message rejected before smart-contract execution'.
|
||||
# This happens when the previous transaction's seqno hasn't been confirmed on-chain yet,
|
||||
# causing the wallet contract to reject the new message.
|
||||
async with _init_ton_client(client) as ton:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
|
||||
# Check balance before broadcasting
|
||||
try:
|
||||
await wallet.refresh()
|
||||
balance_ton = wallet.balance / 1_000_000_000
|
||||
if balance_ton < 0.056:
|
||||
raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton))
|
||||
except WalletError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise WalletError(WalletError.BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
||||
|
||||
try:
|
||||
message = transaction_data["transaction"]["messages"][0]
|
||||
payload = clean_decode(message["payload"])
|
||||
|
||||
result = await wallet.transfer(
|
||||
destination=message["address"],
|
||||
amount=int(message["amount"]), # nanotons, not TON
|
||||
body=payload,
|
||||
)
|
||||
return result.normalized_hash
|
||||
except (WalletError, TransactionError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc
|
||||
|
||||
|
||||
async def get_account_info(client: "FragmentClient") -> dict[str, Any]:
|
||||
async with _init_ton_client(client) as ton:
|
||||
try:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
wallet, pub_key, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
||||
boc = wallet.state_init.serialize().to_boc()
|
||||
return {
|
||||
"address": wallet.address.to_str(False, False),
|
||||
"publicKey": pub_key.as_hex,
|
||||
"chain": "-239",
|
||||
"walletStateInit": base64.b64encode(boc).decode(),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise WalletError(WalletError.ACCOUNT_INFO_FAILED.format(exc=exc)) from exc
|
||||
Reference in New Issue
Block a user