feat: update changelog and examples; unify result fields and improve error messages

This commit is contained in:
bohd4nx
2026-03-21 17:12:26 +02:00
parent 25d70a77ed
commit b55cd00205
10 changed files with 81 additions and 37 deletions
+7
View File
@@ -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 (124 000 winners, 3/6/12 months each) - `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (124 000 winners, 3/6/12 months each)
- `StarsGiveawayResult` and `PremiumGiveawayResult` result types - `StarsGiveawayResult` and `PremiumGiveawayResult` result types
- `STARS_GIVEAWAY_PAGE` and `PREMIUM_GIVEAWAY_PAGE` URL constants - `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 ### Changed
- All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method - 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 - `__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` - 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 - `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
--- ---
+1 -1
View File
@@ -35,7 +35,7 @@ async def main() -> None:
return return
print( 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}"
) )
+1 -1
View File
@@ -35,7 +35,7 @@ async def main() -> None:
return return
print( 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}"
) )
+1 -1
View File
@@ -36,7 +36,7 @@ async def main() -> None:
print(f"Invalid argument: {e}") print(f"Invalid argument: {e}")
return 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__": if __name__ == "__main__":
+2 -2
View File
@@ -120,7 +120,7 @@ class FragmentClient:
show_sender: Show your name as the sender. Defaults to ``True``. show_sender: Show your name as the sender. Defaults to ``True``.
Returns: 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) 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``. show_sender: Show your name as the gift sender. Defaults to ``True``.
Returns: 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) return await purchase_stars(self, username, amount, show_sender)
+1 -1
View File
@@ -91,7 +91,7 @@ async def giveaway_premium(
Returns: Returns:
:class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``,
``winners``, and ``months``. ``winners``, and ``amount``.
Raises: Raises:
ConfigurationError: If ``winners`` is not 124 000 or ``months`` is not 3, 6, or 12. ConfigurationError: If ``winners`` is not 124 000 or ``months`` is not 3, 6, or 12.
+1 -1
View File
@@ -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``. show_sender: Show your name as the gift sender. Defaults to ``True``.
Returns: Returns:
:class:`PremiumResult` with ``transaction_id``, ``username``, and ``months``. :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``.
Raises: Raises:
ConfigurationError: If ``months`` is not ``3``, ``6``, or ``12``. ConfigurationError: If ``months`` is not ``3``, ``6``, or ``12``.
+1 -1
View File
@@ -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``. show_sender: Show your name as the gift sender. Defaults to ``True``.
Returns: Returns:
:class:`StarsResult` with ``transaction_id``, ``username``, and ``stars``. :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``.
Raises: Raises:
ConfigurationError: If ``amount`` is not an integer between 50 and 1 000 000. ConfigurationError: If ``amount`` is not an integer between 50 and 1 000 000.
+52 -24
View File
@@ -11,25 +11,29 @@ class ConfigurationError(ClientError):
MISSING_VARS = "Missing required parameter(s): {keys}." MISSING_VARS = "Missing required parameter(s): {keys}."
UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}." UNSUPPORTED_VERSION = "Unsupported wallet_version '{version}'. Must be one of: {supported}."
INVALID_MNEMONIC = "Invalid mnemonic: got {count} words, expected 12, 18, or 24." INVALID_MNEMONIC = "Invalid mnemonic: expected 12, 18, or 24 words, got {count}."
INVALID_API_KEY = "Invalid Tonapi key: got {length} characters, expected at least 68. Get one at https://tonconsole.com." INVALID_API_KEY = (
INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months." "Invalid Tonapi API key: expected at least 68 characters, got {length}. " "Generate a key at https://tonconsole.com."
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 532 characters: letters (AZ, az), digits (09), or underscores (_)."
) )
INVALID_WINNERS_STARS = "winners must be an integer between 1 and 5." INVALID_MONTHS = "Invalid Premium duration: choose 3, 6, or 12 months."
INVALID_WINNERS_PREMIUM = "winners must be an integer between 1 and 24 000." INVALID_STARS_AMOUNT = "Invalid Stars amount: must be an integer between 50 and 1 000 000."
INVALID_STARS_PER_WINNER = "amount must be an integer between 500 and 1 000 000 stars." INVALID_TON_AMOUNT = "Invalid TON amount: must be an integer between 1 and 1 000 000 000."
INVALID_USERNAME = (
"Invalid username '{username}'. "
"Must be 532 characters and contain only letters (AZ, az), digits (09), 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): class CookieError(ClientError):
"""Raised when cookies are unreadable or missing required fields.""" """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 = ( 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.""" """Raised for errors returned by Fragment's API responses."""
NO_REQUEST_ID = ( 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): class FragmentPageError(FragmentAPIError):
"""Raised when the Fragment page cannot be fetched or the API hash is not found.""" """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 = ( 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.""" """Raised when the target Telegram user is not found on Fragment."""
NOT_FOUND = ( 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.""" """Raised when a TON transaction fails to build or broadcast."""
INVALID_PAYLOAD = ( 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 = "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): class ParseError(FragmentAPIError):
"""Raised when a Fragment API response or payload cannot be parsed.""" """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): class VerificationError(FragmentAPIError):
"""Raised when Fragment requires KYC verification before proceeding.""" """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): class OperationError(FragmentError):
@@ -86,16 +111,19 @@ class OperationError(FragmentError):
class WalletError(OperationError): class WalletError(OperationError):
"""Raised for TON wallet issues (connection, balance, account info).""" """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." LOW_BALANCE = (
BALANCE_CHECK_FAILED = "Wallet balance check failed: {exc}" "Insufficient TON balance: {balance:.4f} TON available, {required:.4f} TON required "
ACCOUNT_INFO_FAILED = "Failed to retrieve wallet account info: {exc}" "(transaction amount + {gas:.3f} TON gas reserve)."
WALLET_INFO_FAILED = "Failed to retrieve wallet info: {exc}" )
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): class UnexpectedError(OperationError):
"""Raised when an unexpected error occurs during an API call.""" """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__ = [ __all__ = [
+14 -5
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import base64 import base64
import ssl
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from tonutils.clients import TonapiClient from tonutils.clients import TonapiClient
@@ -38,9 +39,6 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
message = transaction_data["transaction"]["messages"][0] message = transaction_data["transaction"]["messages"][0]
amount_ton = int(message["amount"]) / 1_000_000_000 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: async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
wallet_cls = WALLET_CLASSES[client.wallet_version] wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed) 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 balance_ton = wallet.balance / 1_000_000_000
required = amount_ton + MIN_TON_BALANCE required = amount_ton + MIN_TON_BALANCE
if balance_ton < required: 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: except WalletError:
raise raise
except Exception as exc: except Exception as exc:
@@ -60,7 +58,7 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
try: try:
payload = clean_decode(message["payload"]) payload = clean_decode(message["payload"])
for attempt in range(2): for attempt in range(3):
try: try:
result = await wallet.transfer( result = await wallet.transfer(
destination=message["address"], destination=message["address"],
@@ -72,10 +70,21 @@ async def process_transaction(client: "FragmentClient", transaction_data: dict)
if exc.code == 429 and attempt == 0: if exc.code == 429 and attempt == 0:
await asyncio.sleep(1) await asyncio.sleep(1)
continue 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 raise
except (WalletError, TransactionError): except (WalletError, TransactionError):
raise raise
except Exception as exc: 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=exc)) from exc
raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result")) raise TransactionError(TransactionError.BROADCAST_FAILED.format(exc="transfer loop exited without result"))