diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c6253..14b491b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,19 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each) - `StarsGiveawayResult` and `PremiumGiveawayResult` result types - `STARS_GIVEAWAY_PAGE` and `PREMIUM_GIVEAWAY_PAGE` URL constants +- `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update +- `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers +- `examples/call.py` — usage example for `client.call()` ### Changed - All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method - `__repr__` on result types now includes the unit (`3 months`, `500 stars`) for clarity - Method module files renamed to match their function: `premium.py` → `purchase_premium.py`, `stars.py` → `purchase_stars.py`, `ton.py` → `topup_ton.py` - `timestamp` field removed from all result dataclasses +- All page URL constants (`STARS_PAGE`, `PREMIUM_PAGE`, etc.) now built from `FRAGMENT_BASE_URL` instead of hardcoded strings +- `TransactionError` now includes an SSL hint when a broadcast fails due to certificate verification errors +- `TransactionError.DUPLICATE_SEQNO` — dedicated error raised after 3 failed attempts due to a `406 Duplicate msg_seqno` response from the TON network; broadcast is automatically retried (up to 2 retries, 2 s apart) before giving up +- All error message templates rewritten to follow a "what happened → why → what to do" pattern — every template is now actionable and includes a fix hint where applicable --- diff --git a/examples/giveaway_premium.py b/examples/giveaway_premium.py index 70ce456..e2a5818 100644 --- a/examples/giveaway_premium.py +++ b/examples/giveaway_premium.py @@ -35,7 +35,7 @@ async def main() -> None: return print( - f"Premium giveaway created for {result.channel} — {result.winners} winners × {result.amount} months | tx: {result.transaction_id}" + f"Premium giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} months each | tx: {result.transaction_id}" ) diff --git a/examples/giveaway_stars.py b/examples/giveaway_stars.py index c35193f..400a707 100644 --- a/examples/giveaway_stars.py +++ b/examples/giveaway_stars.py @@ -35,7 +35,7 @@ async def main() -> None: return print( - f"Stars giveaway created for {result.channel} — {result.winners} winners × {result.amount} stars | tx: {result.transaction_id}" + f"Stars giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} stars each | tx: {result.transaction_id}" ) diff --git a/examples/topup_ton.py b/examples/topup_ton.py index eb0d4ed..f7730c8 100644 --- a/examples/topup_ton.py +++ b/examples/topup_ton.py @@ -36,7 +36,7 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} TON successfully topped up for {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/pyfragment/client.py b/pyfragment/client.py index 4fe9ecc..4e544a1 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -120,7 +120,7 @@ class FragmentClient: show_sender: Show your name as the sender. Defaults to ``True``. Returns: - :class:`PremiumResult` with ``transaction_id``, ``username``, and ``months``. + :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. """ return await purchase_premium(self, username, months, show_sender) @@ -133,7 +133,7 @@ class FragmentClient: show_sender: Show your name as the gift sender. Defaults to ``True``. Returns: - :class:`StarsResult` with ``transaction_id``, ``username``, and ``stars``. + :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. """ return await purchase_stars(self, username, amount, show_sender) diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py index 41148c4..6dfe462 100644 --- a/pyfragment/methods/giveaway_premium.py +++ b/pyfragment/methods/giveaway_premium.py @@ -91,7 +91,7 @@ async def giveaway_premium( Returns: :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, - ``winners``, and ``months``. + ``winners``, and ``amount``. Raises: ConfigurationError: If ``winners`` is not 1–24 000 or ``months`` is not 3, 6, or 12. diff --git a/pyfragment/methods/purchase_premium.py b/pyfragment/methods/purchase_premium.py index 6cd75f9..6e189e5 100644 --- a/pyfragment/methods/purchase_premium.py +++ b/pyfragment/methods/purchase_premium.py @@ -93,7 +93,7 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: Show your name as the gift sender. Defaults to ``True``. Returns: - :class:`PremiumResult` with ``transaction_id``, ``username``, and ``months``. + :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. Raises: ConfigurationError: If ``months`` is not ``3``, ``6``, or ``12``. diff --git a/pyfragment/methods/purchase_stars.py b/pyfragment/methods/purchase_stars.py index 76a1675..21db354 100644 --- a/pyfragment/methods/purchase_stars.py +++ b/pyfragment/methods/purchase_stars.py @@ -80,7 +80,7 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s show_sender: Show your name as the gift sender. Defaults to ``True``. Returns: - :class:`StarsResult` with ``transaction_id``, ``username``, and ``stars``. + :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. Raises: ConfigurationError: If ``amount`` is not an integer between 50 and 1 000 000. diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 949f002..3c5f390 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -11,25 +11,29 @@ class ConfigurationError(ClientError): MISSING_VARS = "Missing required parameter(s): {keys}." UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}." - INVALID_MNEMONIC = "Invalid mnemonic: got {count} words, expected 12, 18, or 24." - INVALID_API_KEY = "Invalid Tonapi key: got {length} characters, expected at least 68. Get one at https://tonconsole.com." - 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." - INVALID_USERNAME = ( - "Invalid username '{username}'. Must be 5–32 characters: letters (A–Z, a–z), digits (0–9), or underscores (_)." + INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}." + INVALID_API_KEY = ( + "Invalid Tonapi API key: expected at least 68 characters, got {length}. " "Generate a key at https://tonconsole.com." ) - INVALID_WINNERS_STARS = "winners must be an integer between 1 and 5." - INVALID_WINNERS_PREMIUM = "winners must be an integer between 1 and 24 000." - INVALID_STARS_PER_WINNER = "amount must be an integer between 500 and 1 000 000 stars." + INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months." + INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000." + INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1 000 000 000." + INVALID_USERNAME = ( + "Invalid username '{username}'. " + "Must be 5–32 characters and contain only letters (A–Z, a–z), digits (0–9), or underscores (_)." + ) + INVALID_WINNERS_STARS = "Invalid winners count: must be an integer between 1 and 5." + INVALID_WINNERS_PREMIUM = "Invalid winners count: must be an integer between 1 and 24 000." + INVALID_STARS_PER_WINNER = "Invalid Stars per winner: must be an integer between 500 and 1 000 000." class CookieError(ClientError): """Raised when cookies are unreadable or missing required fields.""" - READ_FAILED = "Failed to parse cookies: {exc}" + READ_FAILED = "Failed to parse cookies — expected a JSON string or a dict, got: {exc}" MISSING_KEYS = ( - "Cookies are missing or have empty values for: {keys}. " "Open Fragment.com in your browser and copy fresh cookies." + "Fragment cookies are missing or empty for key(s): {keys}. " + "Open fragment.com in your browser, log in, and copy fresh cookies." ) @@ -37,16 +41,21 @@ 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." + "Fragment did not return a request ID for '{context}'. " + "Your session may have expired — log in to fragment.com and 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." + BAD_STATUS = ( + "Fragment returned HTTP {status} when loading {url}. " + "Your cookies may be invalid or expired — log in to fragment.com and refresh them." + ) NOT_FOUND = ( - "Fragment hash not found in the page source of {url}. " "The page structure may have changed or you are not logged in." + "Could not extract the API hash from {url}. " + "The page structure may have changed, or you are not logged in — refresh your cookies." ) @@ -54,7 +63,7 @@ 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." + "Telegram user '{username}' was not found on Fragment. " "Double-check the username and make sure the account exists." ) @@ -62,21 +71,37 @@ 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." + "Fragment returned an invalid transaction payload — " "'transaction.messages' is missing or empty in the API response." ) BROADCAST_FAILED = "Transaction broadcast failed: {exc}" + BROADCAST_FAILED_SSL = ( + "Transaction broadcast failed due to an SSL certificate error: {exc}\n" + "This usually means your system's CA bundle is missing or outdated.\n" + "Fix: run `pip install --upgrade certifi` and retry. " + "On macOS you may also need to run the 'Install Certificates.command' " + "located in your Python installation folder." + ) + DUPLICATE_SEQNO = ( + "Transaction broadcast failed: the TON wallet rejected the message " + "because a previous transaction with the same sequence number (seqno) " + "is still pending confirmation on-chain.\n" + "Wait a few seconds for the previous transaction to confirm, then retry." + ) class ParseError(FragmentAPIError): """Raised when a Fragment API response or payload cannot be parsed.""" - UNPARSEABLE = "Fragment API returned an unparseable response for '{context}': {exc}" + UNPARSEABLE = "Failed to parse the Fragment API 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." + KYC_REQUIRED = ( + "Fragment requires identity verification (KYC) before this action can be completed. " + "Complete verification at https://fragment.com/my/profile and retry." + ) class OperationError(FragmentError): @@ -86,16 +111,19 @@ class OperationError(FragmentError): class WalletError(OperationError): """Raised for TON wallet issues (connection, balance, account info).""" - LOW_BALANCE = "TON wallet balance is too low: {balance:.4f} TON available, {required:.4f} TON required." - BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}" - ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}" - WALLET_INFO_FAILED = "Failed to retrieve wallet info: {exc}" + LOW_BALANCE = ( + "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required " + "(transaction amount + {gas:.3f} TON gas reserve)." + ) + BALANCE_CHECK_FAILED = "Failed to fetch wallet balance: {exc}" + ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info from TON network: {exc}" + WALLET_INFO_FAILED = "Failed to retrieve wallet info from TON network: {exc}" class UnexpectedError(OperationError): """Raised when an unexpected error occurs during an API call.""" - UNEXPECTED = "An unexpected error occurred: {exc}" + UNEXPECTED = "An unexpected error occurred during the operation: {exc}" __all__ = [ diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 0a453ea..db4620c 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -1,5 +1,6 @@ import asyncio import base64 +import ssl from typing import TYPE_CHECKING, Any from tonutils.clients import TonapiClient @@ -38,9 +39,6 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) message = transaction_data["transaction"]["messages"][0] amount_ton = int(message["amount"]) / 1_000_000_000 - # 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 TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton: wallet_cls = WALLET_CLASSES[client.wallet_version] wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) @@ -51,7 +49,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) balance_ton = wallet.balance / 1_000_000_000 required = amount_ton + MIN_TON_BALANCE if balance_ton < required: - raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required)) + raise WalletError(WalletError.LOW_BALANCE.format(balance=balance_ton, required=required, gas=MIN_TON_BALANCE)) except WalletError: raise except Exception as exc: @@ -60,7 +58,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) try: payload = clean_decode(message["payload"]) - for attempt in range(2): + for attempt in range(3): try: result = await wallet.transfer( destination=message["address"], @@ -72,10 +70,21 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict) if exc.code == 429 and attempt == 0: await asyncio.sleep(1) continue + if exc.code == 406 and "seqno" in str(exc).lower(): + # Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry + if attempt < 2: + await asyncio.sleep(2) + continue + raise TransactionError(TransactionError.DUPLICATE_SEQNO) from exc raise except (WalletError, TransactionError): raise except Exception as exc: + cause: BaseException | None = exc + while cause is not None: + if isinstance(cause, ssl.SSLError): + raise TransactionError(TransactionError.BROADCAST_FAILED_SSL.format(exc=exc)) from exc + cause = cause.__cause__ or cause.__context__ raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc=exc)) from exc raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))