mirror of
https://github.com/vibe-existing/pyfragment.git
synced 2026-07-25 06:54:31 +00:00
feat: update changelog and examples; unify result fields and improve error messages
This commit is contained in:
@@ -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)
|
- `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
|
- `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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 1–24 000 or ``months`` is not 3, 6, or 12.
|
ConfigurationError: If ``winners`` is not 1–24 000 or ``months`` is not 3, 6, or 12.
|
||||||
|
|||||||
@@ -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``.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 5–32 characters: letters (A–Z, a–z), digits (0–9), 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 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):
|
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__ = [
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user