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:
bohd4nx
2026-03-15 21:03:03 +02:00
parent 4e017cb7a1
commit d2d046a2b9
38 changed files with 1051 additions and 959 deletions
+59
View File
@@ -0,0 +1,59 @@
from fragmentapi.types.constants import (
BASE_HEADERS,
DEVICE,
PREMIUM_PAGE,
REQUIRED_COOKIE_KEYS,
STARS_PAGE,
SUPPORTED_WALLET_VERSIONS,
TON_PAGE,
WALLET_CLASSES,
WalletVersion,
)
from fragmentapi.types.exceptions import (
ClientError,
ConfigError,
CookiesError,
FragmentAPIError,
FragmentError,
HashFetchError,
OperationError,
RequestError,
TransactionError,
UnexpectedError,
UserNotFoundError,
VerificationError,
WalletError,
)
from fragmentapi.types.results import AdsTopupResult, PremiumResult, StarsResult
__all__ = [
# constants
"BASE_HEADERS",
"DEVICE",
"PREMIUM_PAGE",
"REQUIRED_COOKIE_KEYS",
"STARS_PAGE",
"SUPPORTED_WALLET_VERSIONS",
"TON_PAGE",
"WALLET_CLASSES",
"WalletVersion",
# client exceptions
"ClientError",
"ConfigError",
"CookiesError",
# fragment exceptions
"FragmentAPIError",
"FragmentError",
"HashFetchError",
"OperationError",
"RequestError",
"TransactionError",
"UnexpectedError",
"UserNotFoundError",
"VerificationError",
"WalletError",
# result types
"AdsTopupResult",
"PremiumResult",
"StarsResult",
]
+52
View File
@@ -0,0 +1,52 @@
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}
# 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",
}
+106
View File
@@ -0,0 +1,106 @@
class FragmentError(Exception):
"""Base exception for all fragmentapi library errors."""
class ClientError(FragmentError):
"""Raised for client configuration and setup issues (bad params, invalid cookies)."""
class ConfigError(ClientError):
"""Raised when required client parameters are missing or invalid."""
MISSING_VARS = "Missing required parameter(s): {keys}."
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months."
INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars."
INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON."
class CookiesError(ClientError):
"""Raised when cookies are unreadable or missing required fields."""
READ_FAILED = "Failed to parse cookies: {exc}"
MISSING_KEYS = (
"Cookies are missing or have empty values for: {keys}. " "Open Fragment.com in your browser and copy fresh cookies."
)
class FragmentAPIError(FragmentError):
"""Raised for errors returned by Fragment's API responses."""
NO_REQUEST_ID = (
"Fragment did not return a request ID for '{context}'. " "The session may have expired — refresh your cookies."
)
class HashFetchError(FragmentAPIError):
"""Raised when the Fragment API hash cannot be fetched from the page."""
BAD_STATUS = "Fragment returned HTTP {status} for {url}. " "Check that your cookies are valid and not expired."
NOT_FOUND = (
"Fragment hash not found in the page source of {url}. " "The page structure may have changed or you are not logged in."
)
class UserNotFoundError(FragmentAPIError):
"""Raised when the target Telegram user is not found on Fragment."""
NOT_FOUND = (
"Telegram user '{username}' was not found on Fragment. " "Make sure the username is correct and the account exists."
)
class TransactionError(FragmentAPIError):
"""Raised when a TON transaction fails to build or broadcast."""
INVALID_PAYLOAD = (
"Fragment returned an invalid transaction payload. " "The API response is missing expected 'transaction.messages' data."
)
BROADCAST_FAILED = "Transaction broadcast failed: {exc}"
class RequestError(FragmentAPIError):
"""Raised when a Fragment API response cannot be parsed."""
UNPARSEABLE = "Fragment API returned an unparseable response for '{context}': {exc}"
class VerificationError(FragmentAPIError):
"""Raised when Fragment requires KYC verification before proceeding."""
KYC_REQUIRED = "Fragment requires identity (KYC) verification. " "Complete it at https://fragment.com/my/profile and retry."
class OperationError(FragmentError):
"""Raised for runtime operation failures unrelated to Fragment's API."""
class WalletError(OperationError):
"""Raised for TON wallet issues (connection, balance, account info)."""
LOW_BALANCE = "TON wallet balance is too low: {balance:.2f} TON. Minimum required is 0.056 TON."
BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}"
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}"
class UnexpectedError(OperationError):
"""Raised when an unexpected error occurs during an API call."""
UNEXPECTED = "An unexpected error occurred: {exc}"
__all__ = [
"FragmentError",
"ClientError",
"ConfigError",
"CookiesError",
"FragmentAPIError",
"HashFetchError",
"UserNotFoundError",
"TransactionError",
"RequestError",
"VerificationError",
"OperationError",
"WalletError",
"UnexpectedError",
]
+34
View File
@@ -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()))