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)
- `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
---
+1 -1
View File
@@ -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}"
)
+1 -1
View File
@@ -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}"
)
+1 -1
View File
@@ -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__":
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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 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``.
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``.
+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``.
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.
+52 -24
View File
@@ -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 532 characters: letters (AZ, az), digits (09), 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 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):
"""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__ = [
+14 -5
View File
@@ -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"))