mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
Refactor FragmentAPI to pyfragment
- Renamed the package from `fragmentapi` to `pyfragment` across all modules and tests. - Removed the old wallet utility functions and replaced them with new implementations. - Updated the `pyproject.toml` to reflect the new package name and repository links. - Adjusted all import statements in tests to use the new package name. - Implemented new methods for gifting Telegram Premium, Stars, and topping up TON balance. - Added exception handling for various error scenarios in the API interactions. - Created new utility functions for handling HTTP requests and decoding payloads. - Established a clear structure for types and constants used throughout the library.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# Copyright (c) 2025 bohd4nx
|
||||
#
|
||||
# This source code is licensed under the MIT License found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
from pyfragment.client import FragmentClient
|
||||
from pyfragment.types import (
|
||||
AdsTopupResult,
|
||||
ClientError,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
FragmentPageError,
|
||||
OperationError,
|
||||
ParseError,
|
||||
PremiumResult,
|
||||
StarsResult,
|
||||
TransactionError,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
WalletError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FragmentClient",
|
||||
"AdsTopupResult",
|
||||
"PremiumResult",
|
||||
"StarsResult",
|
||||
"ClientError",
|
||||
"ConfigurationError",
|
||||
"CookieError",
|
||||
"FragmentAPIError",
|
||||
"FragmentError",
|
||||
"FragmentPageError",
|
||||
"OperationError",
|
||||
"ParseError",
|
||||
"TransactionError",
|
||||
"UnexpectedError",
|
||||
"UserNotFoundError",
|
||||
"VerificationError",
|
||||
"WalletError",
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
import json
|
||||
|
||||
from pyfragment.methods.premium import gift_premium
|
||||
from pyfragment.methods.stars import gift_stars
|
||||
from pyfragment.methods.ton import topup_ton
|
||||
from pyfragment.types import (
|
||||
REQUIRED_COOKIE_KEYS,
|
||||
SUPPORTED_WALLET_VERSIONS,
|
||||
AdsTopupResult,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
PremiumResult,
|
||||
StarsResult,
|
||||
WalletVersion,
|
||||
)
|
||||
|
||||
|
||||
class FragmentClient:
|
||||
"""
|
||||
Client for the Fragment.com API.
|
||||
|
||||
Args:
|
||||
seed: 24-word mnemonic phrase for the TON wallet.
|
||||
api_key: Tonapi API key — get one at https://tonconsole.com.
|
||||
cookies: Fragment session cookies as a dict or JSON string.
|
||||
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
|
||||
CookieError: If ``cookies`` cannot be parsed or are missing required keys.
|
||||
|
||||
Example::
|
||||
|
||||
client = FragmentClient(
|
||||
seed="word1 word2 ...",
|
||||
api_key="AAABBB...",
|
||||
cookies={"stel_ssid": "...", "stel_dt": "...", ...},
|
||||
)
|
||||
result = await client.gift_premium("@username", months=6)
|
||||
print(result.transaction_id)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
seed: str,
|
||||
api_key: str,
|
||||
cookies: dict | str,
|
||||
wallet_version: str = "V5R1",
|
||||
) -> None:
|
||||
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
|
||||
if missing:
|
||||
raise ConfigurationError(ConfigurationError.MISSING_VARS.format(keys=", ".join(missing)))
|
||||
|
||||
if isinstance(cookies, str):
|
||||
try:
|
||||
cookies = json.loads(cookies)
|
||||
except Exception as exc:
|
||||
raise CookieError(CookieError.READ_FAILED.format(exc=exc)) from exc
|
||||
|
||||
missing_keys = [k for k in REQUIRED_COOKIE_KEYS if not str(cookies.get(k, "")).strip()]
|
||||
if missing_keys:
|
||||
raise CookieError(CookieError.MISSING_KEYS.format(keys=", ".join(missing_keys)))
|
||||
|
||||
version = wallet_version.strip().upper()
|
||||
if version not in SUPPORTED_WALLET_VERSIONS:
|
||||
raise ConfigurationError(
|
||||
ConfigurationError.UNSUPPORTED_VERSION.format(
|
||||
version=version, supported=", ".join(sorted(SUPPORTED_WALLET_VERSIONS))
|
||||
)
|
||||
)
|
||||
|
||||
self.seed: str = seed.strip()
|
||||
self.api_key: str = api_key.strip()
|
||||
self.cookies: dict = cookies
|
||||
self.wallet_version: WalletVersion = version # type: ignore[assignment]
|
||||
|
||||
async def gift_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult:
|
||||
"""Gift Telegram Premium to a user.
|
||||
|
||||
Args:
|
||||
username: Recipient's Telegram username (with or without ``@``).
|
||||
months: Duration — ``3``, ``6``, or ``12``.
|
||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`PremiumResult` with ``transaction_id``, ``username``, ``months``, ``timestamp``.
|
||||
"""
|
||||
return await gift_premium(self, username, months, show_sender)
|
||||
|
||||
async def gift_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult:
|
||||
"""Gift Telegram Stars to a user.
|
||||
|
||||
Args:
|
||||
username: Recipient's Telegram username (with or without ``@``).
|
||||
amount: Number of stars — integer from ``50`` to ``1 000 000``.
|
||||
show_sender: Show your name as the gift sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`StarsResult` with ``transaction_id``, ``username``, ``stars``, ``timestamp``.
|
||||
"""
|
||||
return await gift_stars(self, username, amount, show_sender)
|
||||
|
||||
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||
"""Top up Telegram Ads balance with TON.
|
||||
|
||||
Args:
|
||||
username: Ads account username (with or without ``@``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsTopupResult` with ``transaction_id``, ``username``, ``amount``, ``timestamp``.
|
||||
"""
|
||||
return await topup_ton(self, username, amount, show_sender)
|
||||
@@ -0,0 +1,5 @@
|
||||
from pyfragment.methods.premium import gift_premium
|
||||
from pyfragment.methods.stars import gift_stars
|
||||
from pyfragment.methods.ton import topup_ton
|
||||
|
||||
__all__ = ["gift_premium", "gift_stars", "topup_ton"]
|
||||
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.types import (
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
PREMIUM_PAGE,
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
PremiumResult,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from pyfragment.utils import (
|
||||
execute_transaction_request,
|
||||
fragment_post,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": PREMIUM_PAGE,
|
||||
"x-aj-referer": PREMIUM_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def _search_recipient(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
months: int,
|
||||
) -> str:
|
||||
result = await fragment_post(
|
||||
session,
|
||||
fragment_hash,
|
||||
HEADERS,
|
||||
{
|
||||
"query": username,
|
||||
"months": months,
|
||||
"method": "searchPremiumGiftRecipient",
|
||||
},
|
||||
)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||
return recipient
|
||||
|
||||
|
||||
async def _init_request(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
recipient: str,
|
||||
months: int,
|
||||
) -> str:
|
||||
await fragment_post(
|
||||
session,
|
||||
fragment_hash,
|
||||
HEADERS,
|
||||
{
|
||||
"mode": "new",
|
||||
"lv": "false",
|
||||
"dh": str(int(time.time())),
|
||||
"method": "updatePremiumState",
|
||||
},
|
||||
)
|
||||
result = await fragment_post(
|
||||
session,
|
||||
fragment_hash,
|
||||
HEADERS,
|
||||
{
|
||||
"recipient": recipient,
|
||||
"months": months,
|
||||
"method": "initGiftPremiumRequest",
|
||||
},
|
||||
)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase"))
|
||||
return req_id
|
||||
|
||||
|
||||
async def gift_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult:
|
||||
if months not in (3, 6, 12):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
|
||||
|
||||
try:
|
||||
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE)
|
||||
account = await get_account_info(client)
|
||||
|
||||
async with httpx.AsyncClient(cookies=client.cookies) as session:
|
||||
recipient = await _search_recipient(session, fragment_hash, username, months)
|
||||
req_id = await _init_request(session, fragment_hash, recipient, months)
|
||||
|
||||
tx_data = {
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
"method": "getGiftPremiumLink",
|
||||
}
|
||||
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
||||
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
return PremiumResult(transaction_id=tx_hash, username=username, months=months)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.types import (
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
STARS_PAGE,
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
StarsResult,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from pyfragment.utils import (
|
||||
execute_transaction_request,
|
||||
fragment_post,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": STARS_PAGE,
|
||||
"x-aj-referer": STARS_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def _search_recipient(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
) -> str:
|
||||
result = await fragment_post(
|
||||
session,
|
||||
fragment_hash,
|
||||
HEADERS,
|
||||
{
|
||||
"query": username,
|
||||
"quantity": "",
|
||||
"method": "searchStarsRecipient",
|
||||
},
|
||||
)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||
return recipient
|
||||
|
||||
|
||||
async def _init_request(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
recipient: str,
|
||||
amount: int,
|
||||
) -> str:
|
||||
result = await fragment_post(
|
||||
session,
|
||||
fragment_hash,
|
||||
HEADERS,
|
||||
{
|
||||
"recipient": recipient,
|
||||
"quantity": amount,
|
||||
"method": "initBuyStarsRequest",
|
||||
},
|
||||
)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase"))
|
||||
return req_id
|
||||
|
||||
|
||||
async def gift_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult:
|
||||
if not isinstance(amount, int) or not (50 <= amount <= 1_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
|
||||
|
||||
try:
|
||||
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE)
|
||||
account = await get_account_info(client)
|
||||
|
||||
async with httpx.AsyncClient(cookies=client.cookies) as session:
|
||||
recipient = await _search_recipient(session, fragment_hash, username)
|
||||
req_id = await _init_request(session, fragment_hash, recipient, amount)
|
||||
|
||||
tx_data = {
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
"method": "getBuyStarsLink",
|
||||
}
|
||||
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
||||
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
return StarsResult(transaction_id=tx_hash, username=username, stars=amount)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.types import (
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
TON_PAGE,
|
||||
AdsTopupResult,
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from pyfragment.utils import (
|
||||
execute_transaction_request,
|
||||
fragment_post,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
# Page-specific headers
|
||||
HEADERS: dict[str, str] = {
|
||||
**BASE_HEADERS,
|
||||
"referer": TON_PAGE,
|
||||
"x-aj-referer": TON_PAGE,
|
||||
}
|
||||
|
||||
|
||||
async def _search_recipient(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
username: str,
|
||||
) -> str:
|
||||
await fragment_post(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"})
|
||||
result = await fragment_post(
|
||||
session,
|
||||
fragment_hash,
|
||||
HEADERS,
|
||||
{
|
||||
"query": username,
|
||||
"method": "searchAdsTopupRecipient",
|
||||
},
|
||||
)
|
||||
recipient = result.get("found", {}).get("recipient")
|
||||
if not recipient:
|
||||
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||
return recipient
|
||||
|
||||
|
||||
async def _init_request(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
recipient: str,
|
||||
amount: int,
|
||||
) -> str:
|
||||
result = await fragment_post(
|
||||
session,
|
||||
fragment_hash,
|
||||
HEADERS,
|
||||
{
|
||||
"recipient": recipient,
|
||||
"amount": amount,
|
||||
"method": "initAdsTopupRequest",
|
||||
},
|
||||
)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
|
||||
return req_id
|
||||
|
||||
|
||||
async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
||||
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
||||
|
||||
try:
|
||||
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE)
|
||||
account = await get_account_info(client)
|
||||
|
||||
async with httpx.AsyncClient(cookies=client.cookies) as session:
|
||||
recipient = await _search_recipient(session, fragment_hash, username)
|
||||
req_id = await _init_request(session, fragment_hash, recipient, amount)
|
||||
|
||||
tx_data = {
|
||||
"account": json.dumps(account),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"show_sender": int(show_sender),
|
||||
"method": "getAdsTopupLink",
|
||||
}
|
||||
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
||||
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -0,0 +1,61 @@
|
||||
from pyfragment.types.constants import (
|
||||
BASE_HEADERS,
|
||||
DEVICE,
|
||||
MIN_TON_BALANCE,
|
||||
PREMIUM_PAGE,
|
||||
REQUIRED_COOKIE_KEYS,
|
||||
STARS_PAGE,
|
||||
SUPPORTED_WALLET_VERSIONS,
|
||||
TON_PAGE,
|
||||
WALLET_CLASSES,
|
||||
WalletVersion,
|
||||
)
|
||||
from pyfragment.types.exceptions import (
|
||||
ClientError,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
FragmentPageError,
|
||||
OperationError,
|
||||
ParseError,
|
||||
TransactionError,
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
VerificationError,
|
||||
WalletError,
|
||||
)
|
||||
from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult
|
||||
|
||||
__all__ = [
|
||||
# constants
|
||||
"BASE_HEADERS",
|
||||
"DEVICE",
|
||||
"MIN_TON_BALANCE",
|
||||
"PREMIUM_PAGE",
|
||||
"REQUIRED_COOKIE_KEYS",
|
||||
"STARS_PAGE",
|
||||
"SUPPORTED_WALLET_VERSIONS",
|
||||
"TON_PAGE",
|
||||
"WALLET_CLASSES",
|
||||
"WalletVersion",
|
||||
# client exceptions
|
||||
"ClientError",
|
||||
"ConfigurationError",
|
||||
"CookieError",
|
||||
# fragment exceptions
|
||||
"FragmentAPIError",
|
||||
"FragmentError",
|
||||
"FragmentPageError",
|
||||
"OperationError",
|
||||
"ParseError",
|
||||
"TransactionError",
|
||||
"UnexpectedError",
|
||||
"UserNotFoundError",
|
||||
"VerificationError",
|
||||
"WalletError",
|
||||
# result types
|
||||
"AdsTopupResult",
|
||||
"PremiumResult",
|
||||
"StarsResult",
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
from typing import Literal, get_args
|
||||
|
||||
from tonutils.contracts.wallet import WalletV4R2, WalletV5R1
|
||||
|
||||
# Single source of truth for supported wallet versions
|
||||
WalletVersion = Literal["V4R2", "V5R1"]
|
||||
SUPPORTED_WALLET_VERSIONS: frozenset[str] = frozenset(get_args(WalletVersion))
|
||||
|
||||
# Wallet class map — used to resolve the correct contract from WALLET_VERSION
|
||||
WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
|
||||
|
||||
# Minimum wallet balance required to cover TON network gas fees.
|
||||
MIN_TON_BALANCE: float = 0.056
|
||||
|
||||
# Required Fragment session cookie keys
|
||||
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
|
||||
|
||||
# Fragment page URLs
|
||||
STARS_PAGE: str = "https://fragment.com/stars/buy"
|
||||
PREMIUM_PAGE: str = "https://fragment.com/premium/gift"
|
||||
TON_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-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,106 @@
|
||||
class FragmentError(Exception):
|
||||
"""Base exception for all pyfragment library errors."""
|
||||
|
||||
|
||||
class ClientError(FragmentError):
|
||||
"""Raised for client configuration and setup issues (bad params, invalid cookies)."""
|
||||
|
||||
|
||||
class ConfigurationError(ClientError):
|
||||
"""Raised when required client parameters are missing or invalid."""
|
||||
|
||||
MISSING_VARS = "Missing required parameter(s): {keys}."
|
||||
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
|
||||
INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months."
|
||||
INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars."
|
||||
INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON."
|
||||
|
||||
|
||||
class CookieError(ClientError):
|
||||
"""Raised when cookies are unreadable or missing required fields."""
|
||||
|
||||
READ_FAILED = "Failed to parse cookies: {exc}"
|
||||
MISSING_KEYS = (
|
||||
"Cookies are missing or have empty values for: {keys}. " "Open Fragment.com in your browser and copy fresh cookies."
|
||||
)
|
||||
|
||||
|
||||
class FragmentAPIError(FragmentError):
|
||||
"""Raised for errors returned by Fragment's API responses."""
|
||||
|
||||
NO_REQUEST_ID = (
|
||||
"Fragment did not return a request ID for '{context}'. " "The session may have expired — refresh your cookies."
|
||||
)
|
||||
|
||||
|
||||
class FragmentPageError(FragmentAPIError):
|
||||
"""Raised when the Fragment page cannot be fetched or the API hash is not found."""
|
||||
|
||||
BAD_STATUS = "Fragment returned HTTP {status} for {url}. " "Check that your cookies are valid and not expired."
|
||||
NOT_FOUND = (
|
||||
"Fragment hash not found in the page source of {url}. " "The page structure may have changed or you are not logged in."
|
||||
)
|
||||
|
||||
|
||||
class UserNotFoundError(FragmentAPIError):
|
||||
"""Raised when the target Telegram user is not found on Fragment."""
|
||||
|
||||
NOT_FOUND = (
|
||||
"Telegram user '{username}' was not found on Fragment. " "Make sure the username is correct and the account exists."
|
||||
)
|
||||
|
||||
|
||||
class TransactionError(FragmentAPIError):
|
||||
"""Raised when a TON transaction fails to build or broadcast."""
|
||||
|
||||
INVALID_PAYLOAD = (
|
||||
"Fragment returned an invalid transaction payload. " "The API response is missing expected 'transaction.messages' data."
|
||||
)
|
||||
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
|
||||
|
||||
|
||||
class ParseError(FragmentAPIError):
|
||||
"""Raised when a Fragment API response or payload cannot be parsed."""
|
||||
|
||||
UNPARSEABLE = "Fragment API returned an unparseable response for '{context}': {exc}"
|
||||
|
||||
|
||||
class VerificationError(FragmentAPIError):
|
||||
"""Raised when Fragment requires KYC verification before proceeding."""
|
||||
|
||||
KYC_REQUIRED = "Fragment requires identity (KYC) verification. " "Complete it at https://fragment.com/my/profile and retry."
|
||||
|
||||
|
||||
class OperationError(FragmentError):
|
||||
"""Raised for runtime operation failures unrelated to Fragment's API."""
|
||||
|
||||
|
||||
class WalletError(OperationError):
|
||||
"""Raised for TON wallet issues (connection, balance, account info)."""
|
||||
|
||||
LOW_BALANCE = "TON wallet balance is too low: {balance:.2f} TON. Minimum required is 0.056 TON."
|
||||
BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}"
|
||||
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}"
|
||||
|
||||
|
||||
class UnexpectedError(OperationError):
|
||||
"""Raised when an unexpected error occurs during an API call."""
|
||||
|
||||
UNEXPECTED = "An unexpected error occurred: {exc}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FragmentError",
|
||||
"ClientError",
|
||||
"ConfigurationError",
|
||||
"CookieError",
|
||||
"FragmentAPIError",
|
||||
"FragmentPageError",
|
||||
"UserNotFoundError",
|
||||
"TransactionError",
|
||||
"ParseError",
|
||||
"VerificationError",
|
||||
"OperationError",
|
||||
"WalletError",
|
||||
"UnexpectedError",
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PremiumResult:
|
||||
"""Result of a successful Telegram Premium gift."""
|
||||
|
||||
transaction_id: str
|
||||
username: str
|
||||
months: int
|
||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
||||
|
||||
|
||||
@dataclass
|
||||
class StarsResult:
|
||||
"""Result of a successful Telegram Stars purchase."""
|
||||
|
||||
transaction_id: str
|
||||
username: str
|
||||
stars: int
|
||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdsTopupResult:
|
||||
"""Result of a successful Telegram Ads balance top-up."""
|
||||
|
||||
transaction_id: str
|
||||
username: str
|
||||
amount: int
|
||||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
||||
@@ -0,0 +1,18 @@
|
||||
from pyfragment.utils.decoder import clean_decode
|
||||
from pyfragment.utils.http import (
|
||||
execute_transaction_request,
|
||||
fragment_post,
|
||||
get_fragment_hash,
|
||||
parse_json_response,
|
||||
)
|
||||
from pyfragment.utils.wallet import get_account_info, process_transaction
|
||||
|
||||
__all__ = [
|
||||
"clean_decode",
|
||||
"execute_transaction_request",
|
||||
"fragment_post",
|
||||
"get_account_info",
|
||||
"get_fragment_hash",
|
||||
"parse_json_response",
|
||||
"process_transaction",
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
import base64
|
||||
|
||||
from pytoniq_core import Cell
|
||||
|
||||
from pyfragment.types import ParseError
|
||||
|
||||
|
||||
def clean_decode(payload: str) -> str:
|
||||
"""Decode a base64-encoded BOC payload to a plain-text comment string.
|
||||
|
||||
Fragment transaction payloads are BOC-serialised TVM cells. This function
|
||||
base64-decodes the payload, parses the cell, skips the 32-bit op-code
|
||||
prefix, and reads the snake-encoded UTF-8 comment.
|
||||
|
||||
Args:
|
||||
payload: Base64url-encoded BOC string (padding is added automatically).
|
||||
|
||||
Returns:
|
||||
Decoded comment string, or ``""`` for an empty payload.
|
||||
|
||||
Raises:
|
||||
ParseError: If the payload cannot be decoded or parsed.
|
||||
"""
|
||||
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 ParseError(ParseError.UNPARSEABLE.format(context="payload decode", exc=exc)) from exc
|
||||
@@ -0,0 +1,134 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.types import FragmentPageError, ParseError, 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:
|
||||
FragmentPageError: 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 FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
|
||||
|
||||
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
|
||||
if not match:
|
||||
raise FragmentPageError(FragmentPageError.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:
|
||||
ParseError: If the response body cannot be decoded as JSON.
|
||||
"""
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as exc:
|
||||
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
||||
|
||||
|
||||
async def fragment_post(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
headers: dict[str, str],
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""POST a single request to the Fragment API.
|
||||
|
||||
Builds the ``/api?hash=`` URL, sends the request, and returns the
|
||||
parsed JSON body. Use this for every API method call — search,
|
||||
init, state updates, etc.
|
||||
|
||||
Args:
|
||||
session: Active httpx session with Fragment cookies.
|
||||
fragment_hash: Short-lived hash from the Fragment page HTML.
|
||||
headers: Page-specific HTTP headers.
|
||||
data: Form data payload; must include a ``"method"`` key.
|
||||
|
||||
Returns:
|
||||
Parsed API response as a dict.
|
||||
"""
|
||||
resp = await session.post(
|
||||
f"https://fragment.com/api?hash={fragment_hash}",
|
||||
headers=headers,
|
||||
data=data,
|
||||
)
|
||||
return parse_json_response(resp, data.get("method", "request"))
|
||||
|
||||
|
||||
async def execute_transaction_request(
|
||||
session: httpx.AsyncClient,
|
||||
headers: dict,
|
||||
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.
|
||||
ParseError: If the response cannot be parsed.
|
||||
"""
|
||||
transaction = await fragment_post(session, fragment_hash, headers, tx_data)
|
||||
|
||||
if transaction.get("need_verify"):
|
||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
||||
|
||||
return transaction
|
||||
@@ -0,0 +1,100 @@
|
||||
import base64
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from tonutils.clients import TonapiClient
|
||||
from tonutils.types import NetworkGlobalID
|
||||
|
||||
from pyfragment.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError
|
||||
from pyfragment.utils.decoder import clean_decode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
|
||||
def _init_ton_client(client: "FragmentClient") -> TonapiClient:
|
||||
return TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key)
|
||||
|
||||
|
||||
async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str:
|
||||
"""Sign and broadcast a Fragment transaction to the TON network.
|
||||
|
||||
Validates the payload structure, checks the wallet balance, decodes the
|
||||
on-chain comment, and calls ``wallet.transfer``.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
transaction_data: Raw transaction dict from ``execute_transaction_request``.
|
||||
|
||||
Returns:
|
||||
Normalised transaction hash string.
|
||||
|
||||
Raises:
|
||||
TransactionError: If the payload is malformed or the broadcast fails.
|
||||
WalletError: If the wallet balance is too low or cannot be fetched.
|
||||
"""
|
||||
if "transaction" not in transaction_data or "messages" not in transaction_data["transaction"]:
|
||||
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
||||
|
||||
# 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 < MIN_TON_BALANCE:
|
||||
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]:
|
||||
"""Fetch wallet address, public key, and state-init for the Fragment API.
|
||||
|
||||
Fragment requires account info to build each transaction payload. The
|
||||
returned dict is JSON-serialised and passed as the ``account`` field in
|
||||
``getBuy*Link`` / ``get*Link`` requests.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
|
||||
Returns:
|
||||
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
|
||||
|
||||
Raises:
|
||||
WalletError: If account info cannot be retrieved.
|
||||
"""
|
||||
async with _init_ton_client(client) as ton:
|
||||
try:
|
||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||
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