diff --git a/CHANGELOG.md b/CHANGELOG.md index 8946978..84e7598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,51 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- +## [2026.1.0] — 2026-03-25 + +### Added + +**Giveaways** +- `giveaway_stars(channel, winners, amount)` — Stars giveaway; 1–5 winners, 500–1 000 000 stars each +- `giveaway_premium(channel, winners, months)` — Premium giveaway; 1–24 000 winners, 3/6/12 months each +- `StarsGiveawayResult`, `PremiumGiveawayResult` result types + +**Telegram Ads** +- `recharge_ads(account, amount)` — top up a Telegram Ads account; 1–1 000 000 000 TON +- `AdsRechargeResult` result type + +**Marketplace** +- `search_usernames(query?, sort?, filter?, offset_id?)` — search Fragment usernames; `sort`: `price_desc / price_asc / listed / ending`, `filter`: `auction / sale / sold` +- `search_numbers(query?, sort?, filter?, offset_id?)` — search Fragment anonymous numbers; same `sort` / `filter` / pagination semantics +- `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search Fragment gifts; `attr` accepts `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}` +- `UsernamesResult`, `NumbersResult`, `GiftsResult` result types + +**Anonymous numbers** +- `get_login_code(number)` — fetch the current pending login code +- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery +- `terminate_sessions(number)` — terminate all active Telegram sessions (two-step flow handled internally) +- `LoginCodeResult`, `TerminateSessionsResult` result types; `AnonymousNumberError` exception + +**Raw API** +- `FragmentClient.call(method, data, *, page_url)` — raw request to any Fragment API method +- `FRAGMENT_BASE_URL` constant — base URL shared across all page constants and headers + +**Examples** +- `examples/client/` — `wallet_info.py` (wallet info), `raw_api_call.py` (raw API call) +- `examples/numbers/` — `manage_number.py` (login code fetch, session termination) +- `examples/auctions/` — `search_usernames.py`, `search_numbers.py`, `search_gifts.py` (marketplace search with pagination) +- `examples/purchase/` — `send_stars.py`, `send_premium.py`, `topup_ton_balance.py`, `run_stars_giveaway.py`, `run_premium_giveaway.py`, `recharge_ads_balance.py` + +### Changed +- All result types now expose a unified `amount` field (`months` and `stars` removed) +- `__repr__` includes the unit — `3 months`, `500 stars`, etc. +- `timestamp` removed from all result dataclasses +- All page URL constants built from `FRAGMENT_BASE_URL`; +- `TransactionError` includes an SSL hint; `DUPLICATE_SEQNO` variant auto-retried up to 2 times (2 s apart) +- Error messages rewritten: "what happened → why → what to do" + +--- + ## [2026.0.2] — 2026-03-20 ### Added @@ -38,5 +83,6 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `py.typed` marker — full PEP 561 typing support for type-checkers - `__repr__` on all result types for readable debug output +[2026.1.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.1.0 [2026.0.2]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.2 [2026.0.1]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.0.1 diff --git a/README.md b/README.md index 34ecbf6..7ab264c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Fragment API

- Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance. + Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts.

[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) @@ -90,11 +90,11 @@ async def main() -> None: try: # Purchase 6 months of Telegram Premium result = await client.purchase_premium("@username", months=6) - print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") # Purchase 500 Stars result = await client.purchase_stars("@username", amount=500) - print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}") # Top up 10 TON to Telegram balance # wallet must hold at least amount + ~0.056 TON for gas diff --git a/examples/auctions/search_gifts.py b/examples/auctions/search_gifts.py new file mode 100644 index 0000000..70ad338 --- /dev/null +++ b/examples/auctions/search_gifts.py @@ -0,0 +1,42 @@ +""" +Example: search the Fragment gifts marketplace. + +collection filters by gift type slug (e.g. "plushpepe", "swisswatch"). +sort can be "price_desc", "price_asc", "listed", or "ending". +filter can be "", "auction", "sale", or "sold". +Use next_offset for pagination. +""" + +import asyncio +import json + +from pyfragment import FragmentClient, GiftsResult + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +QUERY = "" # search text — or omit for all +COLLECTION = "plushpepe" # gift collection slug — or omit for all +SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit +FILTER = "" # "", "auction", "sale", "sold" — or omit + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, sort=SORT, filter=FILTER) + + print(f"Found {len(result.items)} result(s):") + print(json.dumps(result.items, indent=2)) + + if result.next_offset: + print(f"\nMore results available — next page offset: {result.next_offset}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/auctions/search_numbers.py b/examples/auctions/search_numbers.py new file mode 100644 index 0000000..9a98a27 --- /dev/null +++ b/examples/auctions/search_numbers.py @@ -0,0 +1,40 @@ +""" +Example: search the Fragment marketplace for anonymous Telegram numbers. + +sort can be "price_desc", "price_asc", "listed", or "ending". +filter can be "", "auction", "sale", or "sold". +Use next_offset_id for pagination. +""" + +import asyncio +import json + +from pyfragment import FragmentClient, NumbersResult + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +QUERY = "888" # search term — or omit for all +SORT = "price_asc" # "price_desc", "price_asc", "listed", "ending" — or omit +FILTER = "" # "", "auction", "sale", "sold" — or omit + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result: NumbersResult = await client.search_numbers(QUERY, sort=SORT, filter=FILTER) + + print(f"Found {len(result.items)} result(s):") + print(json.dumps(result.items, indent=2)) + + if result.next_offset_id: + print(f"\nMore results available — next page offset: {result.next_offset_id}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/auctions/search_usernames.py b/examples/auctions/search_usernames.py new file mode 100644 index 0000000..68e5ea1 --- /dev/null +++ b/examples/auctions/search_usernames.py @@ -0,0 +1,40 @@ +""" +Example: search the Fragment marketplace for Telegram usernames. + +sort can be "price_desc", "price_asc", "listed", or "ending". +filter can be "", "auction", "sale", or "sold". +Use next_offset_id for pagination. +""" + +import asyncio +import json + +from pyfragment import FragmentClient, UsernamesResult + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +QUERY = "durov" # search term +SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit +FILTER = "auction" # "", "auction", "sale", "sold" — or omit + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result: UsernamesResult = await client.search_usernames(QUERY, sort=SORT, filter=FILTER) + + print(f"Found {len(result.items)} result(s):") + print(json.dumps(result.items, indent=2)) + + if result.next_offset_id: + print(f"\nMore results available — next page offset: {result.next_offset_id}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/client/raw_api_call.py b/examples/client/raw_api_call.py new file mode 100644 index 0000000..6bd9594 --- /dev/null +++ b/examples/client/raw_api_call.py @@ -0,0 +1,37 @@ +""" +Example: send a raw request to any Fragment API method. + +Use client.call() when you need to access a method that is not yet +wrapped by the library, or to inspect raw API responses directly. + +page_url is optional — only set it when the target method belongs to a +specific Fragment page (Fragment derives the API hash per page). +Defaults to the Fragment base URL. +""" + +import asyncio + +from pyfragment import FragmentClient + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +METHOD = "anyFragmentMethod" # replace with the actual method name +DATA = {"key": "value"} # replace with the actual request payload +PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page (optional) + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result = await client.call(METHOD, DATA, page_url=PAGE_URL) + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/get_wallet.py b/examples/client/wallet_info.py similarity index 100% rename from examples/get_wallet.py rename to examples/client/wallet_info.py diff --git a/examples/numbers/manage_number.py b/examples/numbers/manage_number.py new file mode 100644 index 0000000..3355b22 --- /dev/null +++ b/examples/numbers/manage_number.py @@ -0,0 +1,43 @@ +""" +Example: manage an anonymous Telegram number — read login code and terminate sessions. + +Use get_login_code() to fetch the current pending login code for your number. +Use toggle_login_codes() to enable or disable receiving codes. +Use terminate_sessions() to forcefully end all active Telegram sessions. +""" + +import asyncio + +from pyfragment import AnonymousNumberError, FragmentClient + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +NUMBER = "+88888888888" + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + # Fetch the latest login code + result = await client.get_login_code(NUMBER) + if result.code: + print(f"Login code for {result.number}: {result.code} ({result.active_sessions} active session(s))") + else: + print(f"No pending login code for {result.number} ({result.active_sessions} active session(s))") + + # Terminate all active sessions + try: + terminated = await client.terminate_sessions(NUMBER) + print(f"Sessions terminated for {terminated.number}" + (f": {terminated.message}" if terminated.message else "")) + except AnonymousNumberError as e: + print(f"Could not terminate sessions: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py new file mode 100644 index 0000000..6ec6121 --- /dev/null +++ b/examples/purchase/recharge_ads_balance.py @@ -0,0 +1,45 @@ +""" +Example: recharge your own Telegram Ads account with TON. + +Amount must be an integer between 1 and 1 000 000 000 TON. +Your wallet must hold at least the recharge amount + ~0.056 TON for gas. +""" + +import asyncio + +from pyfragment import ( + AdsRechargeResult, + ConfigurationError, + FragmentClient, + WalletError, +) + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account +AMOUNT = 10 # 1–1 000 000 000 TON + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT) + except WalletError as e: + print(f"Wallet error — insufficient balance or misconfiguration: {e}") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return + + print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py new file mode 100644 index 0000000..e2a5818 --- /dev/null +++ b/examples/purchase/run_premium_giveaway.py @@ -0,0 +1,43 @@ +""" +Example: run a Telegram Premium giveaway for a channel. + +winners must be an integer between 1 and 24 000. +months (Premium duration per winner) must be 3, 6, or 12. +""" + +import asyncio + +from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +CHANNEL = "@channel" +WINNERS = 10 # 1–24 000 +MONTHS = 3 # 3, 6 or 12 + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result = await client.giveaway_premium(CHANNEL, winners=WINNERS, months=MONTHS) + except UserNotFoundError: + print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return + + print( + f"Premium giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} months each | tx: {result.transaction_id}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py new file mode 100644 index 0000000..400a707 --- /dev/null +++ b/examples/purchase/run_stars_giveaway.py @@ -0,0 +1,43 @@ +""" +Example: run a Telegram Stars giveaway for a channel. + +winners must be an integer between 1 and 5. +amount (stars per winner) must be an integer between 500 and 1 000 000. +""" + +import asyncio + +from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +CHANNEL = "@channel" +WINNERS = 3 # 1–5 +AMOUNT = 1000 # 500–1 000 000 stars per winner + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result = await client.giveaway_stars(CHANNEL, winners=WINNERS, amount=AMOUNT) + except UserNotFoundError: + print(f"Channel {CHANNEL} was not found on fragment.com — check the username and try again.") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return + + print( + f"Stars giveaway created for {result.channel} — {result.winners} winner(s) × {result.amount} stars each | tx: {result.transaction_id}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/purchase_premium.py b/examples/purchase/send_premium.py similarity index 92% rename from examples/purchase_premium.py rename to examples/purchase/send_premium.py index d35f7c4..fc90f3b 100644 --- a/examples/purchase_premium.py +++ b/examples/purchase/send_premium.py @@ -19,7 +19,7 @@ COOKIES = { } USERNAME = "@username" -MONTHS = 3 # 3, 6, or 12 +MONTHS = 3 # 3, 6 or 12 async def main() -> None: @@ -33,7 +33,7 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/purchase_stars.py b/examples/purchase/send_stars.py similarity index 87% rename from examples/purchase_stars.py rename to examples/purchase/send_stars.py index 4f44ab0..594f883 100644 --- a/examples/purchase_stars.py +++ b/examples/purchase/send_stars.py @@ -19,7 +19,7 @@ COOKIES = { } USERNAME = "@username" -AMOUNT = 500 # 50–1 000 000 +AMOUNT = 500 # 50–1 000 000 stars async def main() -> None: @@ -33,7 +33,7 @@ async def main() -> None: print(f"Invalid argument: {e}") return - print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/topup_ton.py b/examples/purchase/topup_ton_balance.py similarity index 73% rename from examples/topup_ton.py rename to examples/purchase/topup_ton_balance.py index 98c891d..10681ad 100644 --- a/examples/topup_ton.py +++ b/examples/purchase/topup_ton_balance.py @@ -1,5 +1,7 @@ """ -Example: top up a Telegram Ads account with TON. +Example: Topup ton to recipient's Telegram balance. + +For adding TON to Telegram Ads account, use recharge_ads() instead. Amount must be an integer between 1 and 1 000 000 000 TON. Your wallet must hold at least the topup amount + ~0.056 TON for gas. @@ -7,7 +9,12 @@ Your wallet must hold at least the topup amount + ~0.056 TON for gas. import asyncio -from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError, WalletError +from pyfragment import ( + ConfigurationError, + FragmentClient, + UserNotFoundError, + WalletError, +) SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" @@ -19,7 +26,7 @@ COOKIES = { } USERNAME = "@username" -AMOUNT = 10 # TON, integer — 1–1 000 000 000 +AMOUNT = 10 # 1–1 000 000 000 TON async def main() -> None: @@ -36,7 +43,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/fragment.svg b/fragment.svg deleted file mode 100644 index 3d95464..0000000 --- a/fragment.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index d08f26e..495897d 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -7,19 +7,28 @@ from importlib.metadata import version from pyfragment.client import FragmentClient from pyfragment.types import ( + AdsRechargeResult, AdsTopupResult, + AnonymousNumberError, ClientError, ConfigurationError, CookieError, FragmentAPIError, FragmentError, FragmentPageError, + GiftsResult, + LoginCodeResult, + NumbersResult, OperationError, ParseError, + PremiumGiveawayResult, PremiumResult, + StarsGiveawayResult, StarsResult, + TerminateSessionsResult, TransactionError, UnexpectedError, + UsernamesResult, UserNotFoundError, VerificationError, WalletError, @@ -31,9 +40,17 @@ __version__: str = version("pyfragment") __all__ = [ "__version__", "FragmentClient", + "AdsRechargeResult", "AdsTopupResult", + "GiftsResult", + "LoginCodeResult", + "NumbersResult", + "PremiumGiveawayResult", "PremiumResult", + "StarsGiveawayResult", "StarsResult", + "TerminateSessionsResult", + "UsernamesResult", "WalletInfo", "ClientError", "ConfigurationError", @@ -41,6 +58,7 @@ __all__ = [ "FragmentAPIError", "FragmentError", "FragmentPageError", + "AnonymousNumberError", "OperationError", "ParseError", "TransactionError", diff --git a/pyfragment/client.py b/pyfragment/client.py index 066f444..08bd9ab 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -1,18 +1,41 @@ import json -from typing import cast +from typing import Any, cast -from pyfragment.methods.premium import purchase_premium -from pyfragment.methods.stars import purchase_stars -from pyfragment.methods.ton import topup_ton +import httpx + +from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes +from pyfragment.methods.giveaway_premium import giveaway_premium +from pyfragment.methods.giveaway_stars import giveaway_stars +from pyfragment.methods.purchase_premium import purchase_premium +from pyfragment.methods.purchase_stars import purchase_stars +from pyfragment.methods.recharge_ads import recharge_ads +from pyfragment.methods.search_gifts import search_gifts +from pyfragment.methods.search_numbers import search_numbers +from pyfragment.methods.search_usernames import search_usernames +from pyfragment.methods.topup_ton import topup_ton from pyfragment.types import ( + AdsRechargeResult, AdsTopupResult, ConfigurationError, CookieError, + GiftsResult, + LoginCodeResult, + NumbersResult, PremiumResult, StarsResult, + TerminateSessionsResult, + UsernamesResult, WalletInfo, ) -from pyfragment.types.constants import DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, WalletVersion +from pyfragment.types.constants import ( + DEFAULT_TIMEOUT, + FRAGMENT_BASE_URL, + REQUIRED_COOKIE_KEYS, + SUPPORTED_WALLET_VERSIONS, + WalletVersion, +) +from pyfragment.types.results import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers from pyfragment.utils.wallet import get_wallet_info @@ -108,7 +131,7 @@ class FragmentClient: show_sender: Show your name as the sender. Defaults to ``True``. Returns: - :class:`PremiumResult` with ``transaction_id``, ``username``, ``months``, ``timestamp``. + :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. """ return await purchase_premium(self, username, months, show_sender) @@ -121,28 +144,226 @@ class FragmentClient: show_sender: Show your name as the gift sender. Defaults to ``True``. Returns: - :class:`StarsResult` with ``transaction_id``, ``username``, ``stars``, ``timestamp``. + :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. """ return await purchase_stars(self, username, amount, show_sender) async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - """Top up Telegram Ads balance with TON. + """Topup ton to recipient's Telegram balance. Args: - username: Ads account username (with or without ``@``). + username: Recipient's Telegram username (with or without ``@``). amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. show_sender: Show your name as the sender. Defaults to ``True``. Returns: - :class:`AdsTopupResult` with ``transaction_id``, ``username``, ``amount``, ``timestamp``. + :class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``. """ return await topup_ton(self, username, amount, show_sender) + async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult: + """Add funds to your own Telegram Ads account. + + Args: + account: Your Fragment Ads account identifier — the channel or bot username + the Ads account is linked to (e.g. ``"@mychannel"``). + amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. + + Returns: + :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``. + """ + return await recharge_ads(self, account, amount) + async def get_wallet(self) -> WalletInfo: """Return the address, state and balance of the TON wallet. Returns: :class:`WalletInfo` with ``address`` (``"UQ..."``), ``state`` - (``"active"``, ``"uninit"``, or ``"frozen"``), and ``balance`` in TON. + (``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``), and ``balance`` in TON. """ return await get_wallet_info(self) + + async def giveaway_stars( + self, + channel: str, + winners: int, + amount: int, + ) -> StarsGiveawayResult: + """Run a Telegram Stars giveaway for a channel. + + Args: + channel: Channel username (with or without ``@``). + winners: Number of winners — integer from ``1`` to ``5``. + amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. + + Returns: + :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, + ``winners``, and ``amount``. + """ + return await giveaway_stars(self, channel, winners, amount) + + async def giveaway_premium( + self, + channel: str, + winners: int, + months: int = 3, + ) -> PremiumGiveawayResult: + """Run a Telegram Premium giveaway for a channel. + + Args: + channel: Channel username (with or without ``@``). + winners: Number of winners — positive integer. + months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``. + + Returns: + :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, + ``winners``, and ``amount``. + """ + return await giveaway_premium(self, channel, winners, months) + + async def get_login_code(self, number: str) -> LoginCodeResult: + """Fetch the current pending login code for an anonymous number. + + Args: + number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``). + + Returns: + :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending), + and ``active_sessions`` count. + """ + return await get_login_code(self, number) + + async def toggle_login_codes(self, number: str, can_receive: bool) -> None: + """Enable or disable login code delivery for an anonymous number. + + Args: + number: Phone number with or without leading ``+``. + can_receive: ``True`` to allow receiving codes, ``False`` to block them. + """ + return await toggle_login_codes(self, number, can_receive) + + async def terminate_sessions(self, number: str) -> TerminateSessionsResult: + """Terminate all active Telegram sessions for an anonymous number. + + Args: + number: Phone number with or without leading ``+``. + + Returns: + :class:`TerminateSessionsResult` with ``number`` and ``message``. + + Raises: + AnonymousNumberError: If the number is not owned by this account or has no active sessions. + """ + return await terminate_sessions(self, number) + + async def search_usernames( + self, + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, + ) -> UsernamesResult: + """Search the Fragment marketplace for Telegram usernames. + + Args: + query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or + ``""`` (available items). Omit to return all. + offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id` + from a previous result to fetch the next page. + + Returns: + :class:`UsernamesResult` with ``items`` (parsed list of item dicts) + and ``next_offset_id`` (``None`` on the last page). + """ + return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id) + + async def search_numbers( + self, + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, + ) -> NumbersResult: + """Search the Fragment marketplace for anonymous Telegram numbers. + + Args: + query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or + ``""`` (available items). Omit to return all. + offset_id: Pagination cursor — pass :attr:`NumbersResult.next_offset_id` + from a previous result to fetch the next page. + + Returns: + :class:`NumbersResult` with ``items`` (parsed list of item dicts) + and ``next_offset_id`` (``None`` on the last page). + """ + return await search_numbers(self, query, sort=sort, filter=filter, offset_id=offset_id) + + async def search_gifts( + self, + query: str = "", + collection: str | None = None, + sort: str | None = None, + filter: str | None = None, + view: str | None = None, + attr: dict[str, list[str]] | None = None, + offset: int | None = None, + ) -> GiftsResult: + """Search the Fragment gifts marketplace. + + Args: + query: Search text. Omit or pass ``""`` to browse without filtering by name. + collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or + ``""`` (available items). Omit to return all. + view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default. + attr: Attribute filters — mapping of trait name to accepted values, e.g. + ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``. + Each key is sent as ``attr[Key]`` with its list of values. + offset: Integer page offset from a previous :class:`GiftsResult`. + Pass ``next_offset`` to fetch the next page. + + Returns: + :class:`GiftsResult` with ``items`` (parsed list of item dicts) + and ``next_offset`` (``None`` on the last page). + """ + return await search_gifts( + self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset + ) + + async def call( + self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL + ) -> dict[str, Any]: + """Send a raw request to the Fragment API. + + Useful for accessing undocumented or future Fragment API methods + without waiting for a library update. + + Args: + method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``. + data: Additional form-data fields to include in the request body. + page_url: Fragment page URL used to derive the API hash and headers. + Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``). + + Returns: + Raw parsed JSON response as a dict. + + Example:: + + result = await client.call( + "searchPremiumGiftRecipient", + {"query": "@username", "months": 3}, + page_url="https://fragment.com/premium/gift", + ) + """ + headers = make_headers(page_url) + async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session: + fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout) + return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})}) diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py index c0f4e2e..9714024 100644 --- a/pyfragment/methods/__init__.py +++ b/pyfragment/methods/__init__.py @@ -1,5 +1,25 @@ -from pyfragment.methods.premium import purchase_premium -from pyfragment.methods.stars import purchase_stars -from pyfragment.methods.ton import topup_ton +from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes +from pyfragment.methods.giveaway_premium import giveaway_premium +from pyfragment.methods.giveaway_stars import giveaway_stars +from pyfragment.methods.purchase_premium import purchase_premium +from pyfragment.methods.purchase_stars import purchase_stars +from pyfragment.methods.recharge_ads import recharge_ads +from pyfragment.methods.search_gifts import search_gifts +from pyfragment.methods.search_numbers import search_numbers +from pyfragment.methods.search_usernames import search_usernames +from pyfragment.methods.topup_ton import topup_ton -__all__ = ["purchase_premium", "purchase_stars", "topup_ton"] +__all__ = [ + "get_login_code", + "giveaway_premium", + "giveaway_stars", + "purchase_premium", + "purchase_stars", + "recharge_ads", + "search_gifts", + "search_numbers", + "search_usernames", + "terminate_sessions", + "toggle_login_codes", + "topup_ton", +] diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/methods/anonymous_number.py new file mode 100644 index 0000000..b8facfe --- /dev/null +++ b/pyfragment/methods/anonymous_number.py @@ -0,0 +1,151 @@ +import html +from typing import TYPE_CHECKING + +import httpx + +from pyfragment.types import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types.constants import NUMBERS_PAGE +from pyfragment.types.results import LoginCodeResult, TerminateSessionsResult +from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_login_code + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(NUMBERS_PAGE) + + +def _strip_plus(number: str) -> str: + return number.lstrip("+") if isinstance(number, str) else number + + +async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResult: + """Fetch the current pending login code for an anonymous number. + + Args: + client: Authenticated :class:`FragmentClient` instance. + number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``). + + Returns: + :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if no pending code), + and ``active_sessions`` count. + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + try: + clean = _strip_plus(number) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) + result = await fragment_request( + session, + fragment_hash, + HEADERS, + {"number": clean, "lt": "0", "from_app": "1", "method": "updateLoginCodes"}, + ) + + if result.get("html"): + code, active_sessions = parse_login_code(result["html"]) + else: + code, active_sessions = None, 0 + + return LoginCodeResult(number=number, code=code, active_sessions=active_sessions) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc + + +async def toggle_login_codes(client: "FragmentClient", number: str, can_receive: bool) -> None: + """Enable or disable login code delivery for an anonymous number. + + Args: + client: Authenticated :class:`FragmentClient` instance. + number: Phone number with or without leading ``+``. + can_receive: ``True`` to allow receiving codes, ``False`` to block them. + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + try: + clean = _strip_plus(number) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) + result = await fragment_request( + session, + fragment_hash, + HEADERS, + {"number": clean, "can_receive": 1 if can_receive else 0, "method": "toggleLoginCodes"}, + ) + + if result.get("error"): + raise FragmentAPIError(html.unescape(result["error"])) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc + + +async def terminate_sessions(client: "FragmentClient", number: str) -> TerminateSessionsResult: + """Terminate all active Telegram sessions for an anonymous number. + + This is a two-step operation: Fragment first returns a confirmation hash, + which is then submitted to confirm the termination. + + Args: + client: Authenticated :class:`FragmentClient` instance. + number: Phone number with or without leading ``+``. + + Returns: + :class:`TerminateSessionsResult` with ``number`` and ``message``. + + Raises: + AnonymousNumberError: If the number is not owned by this account or has no active sessions, + or if Fragment returns an error during termination. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + try: + clean = _strip_plus(number) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) + + # Step 1: initiate — Fragment returns a confirmation hash. + confirmation = await fragment_request( + session, + fragment_hash, + HEADERS, + {"number": clean, "method": "terminatePhoneSessions"}, + ) + + if confirmation.get("error"): + raise AnonymousNumberError( + AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(confirmation["error"])) + ) + + terminate_hash = confirmation.get("terminate_hash") + if not terminate_hash: + raise AnonymousNumberError(AnonymousNumberError.NOT_OWNED.format(number=number)) + + # Step 2: confirm with the hash. + result = await fragment_request( + session, + fragment_hash, + HEADERS, + {"number": clean, "terminate_hash": terminate_hash, "method": "terminatePhoneSessions"}, + ) + + if result.get("error"): + raise AnonymousNumberError( + AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(result["error"])) + ) + + return TerminateSessionsResult(number=number, message=result.get("msg")) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py new file mode 100644 index 0000000..6dfe462 --- /dev/null +++ b/pyfragment/methods/giveaway_premium.py @@ -0,0 +1,135 @@ +import json +from typing import TYPE_CHECKING + +import httpx + +from pyfragment.types import ( + ConfigurationError, + FragmentAPIError, + FragmentError, + UnexpectedError, + UserNotFoundError, +) +from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE +from pyfragment.types.results import PremiumGiveawayResult +from pyfragment.utils import ( + execute_transaction_request, + fragment_request, + get_account_info, + get_fragment_hash, + make_headers, + process_transaction, +) + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(PREMIUM_GIVEAWAY_PAGE) + + +async def _search_recipient( + session: httpx.AsyncClient, + fragment_hash: str, + channel: str, + winners: int, + months: int, +) -> str: + result = await fragment_request( + session, + fragment_hash, + HEADERS, + { + "query": channel, + "quantity": winners, + "months": months, + "method": "searchPremiumGiveawayRecipient", + }, + ) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + return recipient + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + recipient: str, + winners: int, + months: int, +) -> str: + result = await fragment_request( + session, + fragment_hash, + HEADERS, + { + "recipient": recipient, + "quantity": str(winners), + "months": str(months), + "method": "initGiveawayPremiumRequest", + }, + ) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway")) + return req_id + + +async def giveaway_premium( + client: "FragmentClient", + channel: str, + winners: int, + months: int = 3, +) -> PremiumGiveawayResult: + """Run a Telegram Premium giveaway for a channel. + + Args: + client: Authenticated :class:`FragmentClient` instance. + channel: Channel username (with or without ``@``). + winners: Number of winners — integer from ``1`` to ``24 000``. + months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``. + + Returns: + :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, + ``winners``, and ``amount``. + + Raises: + ConfigurationError: If ``winners`` is not 1–24 000 or ``months`` is not 3, 6, or 12. + UserNotFoundError: If the channel is not found on Fragment. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + if not isinstance(winners, int) or not (1 <= winners <= 24_000): + raise ConfigurationError(ConfigurationError.INVALID_WINNERS_PREMIUM) + if months not in (3, 6, 12): + raise ConfigurationError(ConfigurationError.INVALID_MONTHS) + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_GIVEAWAY_PAGE, client.timeout) + account = await get_account_info(client) + + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + recipient = await _search_recipient(session, fragment_hash, channel, winners, months) + req_id = await _init_request(session, fragment_hash, recipient, winners, months) + + tx_data = { + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "method": "getGiveawayPremiumLink", + } + transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + + tx_hash = await process_transaction(client, transaction) + return PremiumGiveawayResult( + transaction_id=tx_hash, + channel=channel, + winners=winners, + amount=months, + ) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/giveaway_stars.py b/pyfragment/methods/giveaway_stars.py new file mode 100644 index 0000000..5eb4ef8 --- /dev/null +++ b/pyfragment/methods/giveaway_stars.py @@ -0,0 +1,131 @@ +import json +from typing import TYPE_CHECKING + +import httpx + +from pyfragment.types import ( + ConfigurationError, + FragmentAPIError, + FragmentError, + UnexpectedError, + UserNotFoundError, +) +from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE +from pyfragment.types.results import StarsGiveawayResult +from pyfragment.utils import ( + execute_transaction_request, + fragment_request, + get_account_info, + get_fragment_hash, + make_headers, + process_transaction, +) + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(STARS_GIVEAWAY_PAGE) + + +async def _search_recipient( + session: httpx.AsyncClient, + fragment_hash: str, + channel: str, +) -> str: + result = await fragment_request( + session, + fragment_hash, + HEADERS, + { + "query": channel, + "method": "searchStarsGiveawayRecipient", + }, + ) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + return recipient + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + recipient: str, + winners: int, + amount: int, +) -> str: + result = await fragment_request( + session, + fragment_hash, + HEADERS, + { + "recipient": recipient, + "quantity": str(winners), + "stars": str(amount), + "method": "initGiveawayStarsRequest", + }, + ) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway")) + return req_id + + +async def giveaway_stars( + client: "FragmentClient", + channel: str, + winners: int, + amount: int, +) -> StarsGiveawayResult: + """Run a Telegram Stars giveaway for a channel. + + Args: + client: Authenticated :class:`FragmentClient` instance. + channel: Channel username (with or without ``@``). + winners: Number of winners — integer from ``1`` to ``5``. + amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. + + Returns: + :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, + ``winners``, and ``amount``. + + Raises: + ConfigurationError: If ``winners`` is not 1–5 or ``amount`` is not 500–1 000 000. + UserNotFoundError: If the channel is not found on Fragment. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + if not isinstance(winners, int) or not (1 <= winners <= 5): + raise ConfigurationError(ConfigurationError.INVALID_WINNERS_STARS) + if not isinstance(amount, int) or not (500 <= amount <= 1_000_000): + raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER) + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_GIVEAWAY_PAGE, client.timeout) + account = await get_account_info(client) + + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + recipient = await _search_recipient(session, fragment_hash, channel) + req_id = await _init_request(session, fragment_hash, recipient, winners, amount) + + tx_data = { + "account": json.dumps(account), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "method": "getGiveawayStarsLink", + } + transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + + tx_hash = await process_transaction(client, transaction) + return StarsGiveawayResult( + transaction_id=tx_hash, + channel=channel, + winners=winners, + amount=amount, + ) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/premium.py b/pyfragment/methods/purchase_premium.py similarity index 75% rename from pyfragment/methods/premium.py rename to pyfragment/methods/purchase_premium.py index 598a787..6e189e5 100644 --- a/pyfragment/methods/premium.py +++ b/pyfragment/methods/purchase_premium.py @@ -12,24 +12,20 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) -from pyfragment.types.constants import BASE_HEADERS, DEVICE, PREMIUM_PAGE +from pyfragment.types.constants import DEVICE, PREMIUM_PAGE from pyfragment.utils import ( execute_transaction_request, - fragment_post, + fragment_request, get_account_info, get_fragment_hash, + make_headers, process_transaction, ) if TYPE_CHECKING: from pyfragment.client import FragmentClient -# Page-specific headers -HEADERS: dict[str, str] = { - **BASE_HEADERS, - "referer": PREMIUM_PAGE, - "x-aj-referer": PREMIUM_PAGE, -} +HEADERS: dict[str, str] = make_headers(PREMIUM_PAGE) async def _search_recipient( @@ -38,7 +34,7 @@ async def _search_recipient( username: str, months: int, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, @@ -60,7 +56,7 @@ async def _init_request( recipient: str, months: int, ) -> str: - await fragment_post( + await fragment_request( session, fragment_hash, HEADERS, @@ -71,7 +67,7 @@ async def _init_request( "method": "updatePremiumState", }, ) - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, @@ -88,6 +84,23 @@ async def _init_request( async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult: + """Gift Telegram Premium to a user. + + Args: + client: Authenticated :class:`FragmentClient` instance. + username: Recipient's Telegram username (with or without ``@``). + months: Premium duration — ``3``, ``6``, or ``12``. + show_sender: Show your name as the gift sender. Defaults to ``True``. + + Returns: + :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. + + Raises: + ConfigurationError: If ``months`` is not ``3``, ``6``, or ``12``. + UserNotFoundError: If the user is not found on Fragment. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ if months not in (3, 6, 12): raise ConfigurationError(ConfigurationError.INVALID_MONTHS) @@ -110,7 +123,7 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int, transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) tx_hash = await process_transaction(client, transaction) - return PremiumResult(transaction_id=tx_hash, username=username, months=months) + return PremiumResult(transaction_id=tx_hash, username=username, amount=months) except FragmentError: raise diff --git a/pyfragment/methods/stars.py b/pyfragment/methods/purchase_stars.py similarity index 73% rename from pyfragment/methods/stars.py rename to pyfragment/methods/purchase_stars.py index 874593d..21db354 100644 --- a/pyfragment/methods/stars.py +++ b/pyfragment/methods/purchase_stars.py @@ -11,24 +11,20 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) -from pyfragment.types.constants import BASE_HEADERS, DEVICE, STARS_PAGE +from pyfragment.types.constants import DEVICE, STARS_PAGE from pyfragment.utils import ( execute_transaction_request, - fragment_post, + fragment_request, get_account_info, get_fragment_hash, + make_headers, process_transaction, ) if TYPE_CHECKING: from pyfragment.client import FragmentClient -# Page-specific headers -HEADERS: dict[str, str] = { - **BASE_HEADERS, - "referer": STARS_PAGE, - "x-aj-referer": STARS_PAGE, -} +HEADERS: dict[str, str] = make_headers(STARS_PAGE) async def _search_recipient( @@ -36,7 +32,7 @@ async def _search_recipient( fragment_hash: str, username: str, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, @@ -58,7 +54,7 @@ async def _init_request( recipient: str, amount: int, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, @@ -75,6 +71,23 @@ async def _init_request( async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult: + """Send Telegram Stars to a user. + + Args: + client: Authenticated :class:`FragmentClient` instance. + username: Recipient's Telegram username (with or without ``@``). + amount: Number of Stars to send — integer from ``50`` to ``1 000 000``. + show_sender: Show your name as the gift sender. Defaults to ``True``. + + Returns: + :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. + + Raises: + ConfigurationError: If ``amount`` is not an integer between 50 and 1 000 000. + UserNotFoundError: If the user is not found on Fragment. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ if not isinstance(amount, int) or not (50 <= amount <= 1_000_000): raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) @@ -97,7 +110,7 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) tx_hash = await process_transaction(client, transaction) - return StarsResult(transaction_id=tx_hash, username=username, stars=amount) + return StarsResult(transaction_id=tx_hash, username=username, amount=amount) except FragmentError: raise diff --git a/pyfragment/methods/recharge_ads.py b/pyfragment/methods/recharge_ads.py new file mode 100644 index 0000000..7d27669 --- /dev/null +++ b/pyfragment/methods/recharge_ads.py @@ -0,0 +1,94 @@ +import json +from typing import TYPE_CHECKING + +import httpx + +from pyfragment.types import ( + ConfigurationError, + FragmentAPIError, + FragmentError, + UnexpectedError, +) +from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE +from pyfragment.types.results import AdsRechargeResult +from pyfragment.utils import ( + execute_transaction_request, + fragment_request, + get_account_info, + get_fragment_hash, + make_headers, + process_transaction, +) + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE) + + +async def _init_request( + session: httpx.AsyncClient, + fragment_hash: str, + account: str, + amount: int, +) -> str: + result = await fragment_request( + session, + fragment_hash, + HEADERS, + { + "account": account, + "amount": amount, + "method": "initAdsRechargeRequest", + }, + ) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge")) + return req_id + + +async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> AdsRechargeResult: + """Add funds to your own Telegram Ads account. + + Args: + client: Authenticated :class:`FragmentClient` instance. + account: Your Fragment Ads account identifier — the channel or bot username + the Ads account is linked to (e.g. ``"@mychannel"``). + amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. + + Returns: + :class:`AdsRechargeResult` with ``transaction_id`` and ``amount``. + + Raises: + ConfigurationError: If ``amount`` is not a valid integer in the allowed range. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): + raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout) + account_info = await get_account_info(client) + + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + await fragment_request(session, fragment_hash, HEADERS, {"method": "updateAdsState", "mode": "new"}) + req_id = await _init_request(session, fragment_hash, account, amount) + + tx_data = { + "account": json.dumps(account_info), + "device": DEVICE, + "transaction": 1, + "id": req_id, + "method": "getAdsRechargeLink", + } + transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + + tx_hash = await process_transaction(client, transaction) + return AdsRechargeResult(transaction_id=tx_hash, amount=amount) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/search_gifts.py b/pyfragment/methods/search_gifts.py new file mode 100644 index 0000000..d1276b4 --- /dev/null +++ b/pyfragment/methods/search_gifts.py @@ -0,0 +1,80 @@ +from typing import TYPE_CHECKING, Any + +import httpx + +from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types.constants import GIFTS_PAGE +from pyfragment.types.results import GiftsResult +from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_gift_items + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(GIFTS_PAGE) + + +async def search_gifts( + client: "FragmentClient", + query: str = "", + collection: str | None = None, + sort: str | None = None, + filter: str | None = None, + view: str | None = None, + attr: dict[str, list[str]] | None = None, + offset: int | None = None, +) -> GiftsResult: + """Search the Fragment gifts marketplace. + + Args: + client: Authenticated :class:`FragmentClient` instance. + query: Search text. Omit or pass ``""`` to browse without filtering by name. + collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` + (available items). Omit to return all. + view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default. + attr: Attribute filters as a mapping of trait name to list of accepted values, e.g. + ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``. + Each key is sent as ``attr[Key]`` with its list of values. + offset: Integer page offset from a previous :class:`GiftsResult`. + Pass ``next_offset`` to fetch the next page. + + Returns: + :class:`GiftsResult` with ``items`` (parsed list of item dicts) and + ``next_offset`` (``None`` on the last page). + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + data: dict[str, Any] = {"method": "searchAuctions", "type": "gifts", "query": query} + if collection is not None: + data["collection"] = collection + if sort is not None: + data["sort"] = sort + if filter is not None: + data["filter"] = filter + if view is not None: + data["view"] = view + if attr is not None: + for trait, values in attr.items(): + data[f"attr[{trait}]"] = values + if offset is not None: + data["offset"] = offset + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, GIFTS_PAGE, client.timeout) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + result = await fragment_request(session, fragment_hash, HEADERS, data) + + if result.get("error"): + raise FragmentAPIError(result["error"]) + + items, next_offset = parse_gift_items(result.get("html") or "") + return GiftsResult(items=items, next_offset=next_offset) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/search_numbers.py b/pyfragment/methods/search_numbers.py new file mode 100644 index 0000000..e6f459a --- /dev/null +++ b/pyfragment/methods/search_numbers.py @@ -0,0 +1,67 @@ +from typing import TYPE_CHECKING, Any + +import httpx + +from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types.constants import NUMBERS_PAGE +from pyfragment.types.results import NumbersResult +from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(NUMBERS_PAGE) + + +async def search_numbers( + client: "FragmentClient", + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, +) -> NumbersResult: + """Search the Fragment marketplace for anonymous Telegram numbers. + + Args: + client: Authenticated :class:`FragmentClient` instance. + query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` + (available items). Omit to return all. + offset_id: Pagination cursor from a previous :class:`NumbersResult`. + Pass ``next_offset_id`` to fetch the next page. + + Returns: + :class:`NumbersResult` with ``items`` (parsed list of item dicts) and + ``next_offset_id`` (``None`` when there are no more pages). + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + data: dict[str, Any] = {"method": "searchAuctions", "type": "numbers", "query": query} + if sort is not None: + data["sort"] = sort + if filter is not None: + data["filter"] = filter + if offset_id is not None: + data["offset_id"] = offset_id + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + result = await fragment_request(session, fragment_hash, HEADERS, data) + + if result.get("error"): + raise FragmentAPIError(result["error"]) + + items = parse_auction_rows(result.get("html") or "") + raw_noi = result.get("next_offset_id") + next_offset_id = str(raw_noi) if raw_noi else None + return NumbersResult(items=items, next_offset_id=next_offset_id) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/search_usernames.py b/pyfragment/methods/search_usernames.py new file mode 100644 index 0000000..23af084 --- /dev/null +++ b/pyfragment/methods/search_usernames.py @@ -0,0 +1,67 @@ +from typing import TYPE_CHECKING, Any + +import httpx + +from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types.constants import FRAGMENT_BASE_URL +from pyfragment.types.results import UsernamesResult +from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(FRAGMENT_BASE_URL) + + +async def search_usernames( + client: "FragmentClient", + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, +) -> UsernamesResult: + """Search the Fragment marketplace for Telegram usernames. + + Args: + client: Authenticated :class:`FragmentClient` instance. + query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` + (available items). Omit to return all. + offset_id: Pagination cursor from a previous :class:`UsernamesResult`. + Pass ``next_offset_id`` to fetch the next page. + + Returns: + :class:`UsernamesResult` with ``items`` (parsed list of item dicts) and + ``next_offset_id`` (``None`` when there are no more pages). + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + data: dict[str, Any] = {"method": "searchAuctions", "type": "usernames", "query": query} + if sort is not None: + data["sort"] = sort + if filter is not None: + data["filter"] = filter + if offset_id is not None: + data["offset_id"] = offset_id + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, FRAGMENT_BASE_URL, client.timeout) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + result = await fragment_request(session, fragment_hash, HEADERS, data) + + if result.get("error"): + raise FragmentAPIError(result["error"]) + + items = parse_auction_rows(result.get("html") or "") + raw_noi = result.get("next_offset_id") + next_offset_id = str(raw_noi) if raw_noi else None + return UsernamesResult(items=items, next_offset_id=next_offset_id) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/ton.py b/pyfragment/methods/topup_ton.py similarity index 70% rename from pyfragment/methods/ton.py rename to pyfragment/methods/topup_ton.py index 44da5bb..117ea87 100644 --- a/pyfragment/methods/ton.py +++ b/pyfragment/methods/topup_ton.py @@ -11,24 +11,20 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) -from pyfragment.types.constants import BASE_HEADERS, DEVICE, TON_PAGE +from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE from pyfragment.utils import ( execute_transaction_request, - fragment_post, + fragment_request, get_account_info, get_fragment_hash, + make_headers, process_transaction, ) if TYPE_CHECKING: from pyfragment.client import FragmentClient -# Page-specific headers -HEADERS: dict[str, str] = { - **BASE_HEADERS, - "referer": TON_PAGE, - "x-aj-referer": TON_PAGE, -} +HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE) async def _search_recipient( @@ -36,8 +32,8 @@ async def _search_recipient( fragment_hash: str, username: str, ) -> str: - await fragment_post(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"}) - result = await fragment_post( + await fragment_request(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"}) + result = await fragment_request( session, fragment_hash, HEADERS, @@ -58,7 +54,7 @@ async def _init_request( recipient: str, amount: int, ) -> str: - result = await fragment_post( + result = await fragment_request( session, fragment_hash, HEADERS, @@ -75,11 +71,28 @@ async def _init_request( async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: + """Topup ton to recipient's Telegram balance. + + Args: + client: Authenticated :class:`FragmentClient` instance. + username: Recipient's Telegram username (with or without ``@``). + amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. + show_sender: Show your name as the sender. Defaults to ``True``. + + Returns: + :class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``. + + Raises: + ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000. + UserNotFoundError: If the recipient is not found on Fragment. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000): raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE, client.timeout) + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout) account = await get_account_info(client) async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 042f02b..0563be0 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -1,4 +1,5 @@ from pyfragment.types.exceptions import ( + AnonymousNumberError, ClientError, ConfigurationError, CookieError, @@ -13,7 +14,20 @@ from pyfragment.types.exceptions import ( VerificationError, WalletError, ) -from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo +from pyfragment.types.results import ( + AdsRechargeResult, + AdsTopupResult, + GiftsResult, + LoginCodeResult, + NumbersResult, + PremiumGiveawayResult, + PremiumResult, + StarsGiveawayResult, + StarsResult, + TerminateSessionsResult, + UsernamesResult, + WalletInfo, +) __all__ = [ # client exceptions @@ -24,6 +38,7 @@ __all__ = [ "FragmentAPIError", "FragmentError", "FragmentPageError", + "AnonymousNumberError", "OperationError", "ParseError", "TransactionError", @@ -32,8 +47,16 @@ __all__ = [ "VerificationError", "WalletError", # result types + "AdsRechargeResult", "AdsTopupResult", + "GiftsResult", + "LoginCodeResult", + "NumbersResult", + "PremiumGiveawayResult", "PremiumResult", + "StarsGiveawayResult", "StarsResult", + "TerminateSessionsResult", + "UsernamesResult", "WalletInfo", ] diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index 362a850..fee850a 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -20,9 +20,14 @@ DEFAULT_TIMEOUT: float = 30.0 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" +FRAGMENT_BASE_URL: str = "https://fragment.com" +STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" +STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" +PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" +PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" +ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" +NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" +GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. DEVICE: str = json.dumps( @@ -45,7 +50,7 @@ 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", + "origin": FRAGMENT_BASE_URL, "priority": "u=1, i", "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 1cc2283..5207a94 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -11,22 +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_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." ) @@ -34,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." ) @@ -51,29 +63,52 @@ 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." ) +class AnonymousNumberError(FragmentAPIError): + """Raised for Fragment anonymous number API failures.""" + + NOT_OWNED = "Number '{number}' is not associated with your Fragment account or has no active sessions to terminate." + TERMINATE_FAILED = "Failed to terminate sessions for '{number}': {error}" + + 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): @@ -83,16 +118,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__ = [ @@ -102,6 +140,7 @@ __all__ = [ "CookieError", "FragmentAPIError", "FragmentPageError", + "AnonymousNumberError", "UserNotFoundError", "TransactionError", "ParseError", diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index a6ad138..89eb403 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -1,7 +1,5 @@ -import time -from dataclasses import dataclass, field - -__all__ = ["AdsTopupResult", "PremiumResult", "StarsResult", "WalletInfo"] +from dataclasses import dataclass +from typing import Any @dataclass @@ -22,11 +20,10 @@ class PremiumResult: transaction_id: str username: str - months: int - timestamp: int = field(default_factory=lambda: int(time.time())) + amount: int def __repr__(self) -> str: - return f"PremiumResult(username='{self.username}', months={self.months}, tx='{self.transaction_id}')" + return f"PremiumResult(username='{self.username}', amount={self.amount} months, tx='{self.transaction_id}')" @dataclass @@ -35,11 +32,10 @@ class StarsResult: transaction_id: str username: str - stars: int - timestamp: int = field(default_factory=lambda: int(time.time())) + amount: int def __repr__(self) -> str: - return f"StarsResult(username='{self.username}', stars={self.stars}, tx='{self.transaction_id}')" + return f"StarsResult(username='{self.username}', amount={self.amount} stars, tx='{self.transaction_id}')" @dataclass @@ -49,7 +45,155 @@ class AdsTopupResult: transaction_id: str username: str amount: int - timestamp: int = field(default_factory=lambda: int(time.time())) def __repr__(self) -> str: return f"AdsTopupResult(username='{self.username}', amount={self.amount} TON, tx='{self.transaction_id}')" + + +@dataclass +class StarsGiveawayResult: + """Result of a successful Telegram Stars giveaway.""" + + transaction_id: str + channel: str + winners: int + amount: int + + def __repr__(self) -> str: + return ( + f"StarsGiveawayResult(channel='{self.channel}', winners={self.winners}, " + f"amount={self.amount} stars per winner, tx='{self.transaction_id}')" + ) + + +@dataclass +class PremiumGiveawayResult: + """Result of a successful Telegram Premium giveaway.""" + + transaction_id: str + channel: str + winners: int + amount: int + + def __repr__(self) -> str: + return ( + f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, " + f"amount={self.amount} months per winner, tx='{self.transaction_id}')" + ) + + +@dataclass +class LoginCodeResult: + """Result of :meth:`FragmentClient.get_login_code`.""" + + number: str + code: str | None + active_sessions: int + + def __repr__(self) -> str: + code_str = f"'{self.code}'" if self.code else "None" + return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})" + + +@dataclass +class AdsRechargeResult: + """Result of a successful self-recharge of Telegram Ads balance.""" + + transaction_id: str + amount: int + + def __repr__(self) -> str: + return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')" + + +@dataclass +class TerminateSessionsResult: + """Result of :meth:`FragmentClient.terminate_sessions`.""" + + number: str + message: str | None + + def __repr__(self) -> str: + return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})" + + +@dataclass +class UsernamesResult: + """Result of :meth:`FragmentClient.search_usernames`. + + Each dict in ``items`` has the keys: + + - ``slug`` — URL path (e.g. ``"username/durov"``). + - ``name`` — display value (e.g. ``"@durov"``). + - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``. + - ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``. + + Use ``next_offset_id`` to paginate to the next page of results. + """ + + items: list[dict[str, Any]] + next_offset_id: str | None + + def __repr__(self) -> str: + return f"UsernamesResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + + +@dataclass +class NumbersResult: + """Result of :meth:`FragmentClient.search_numbers`. + + Each dict in ``items`` has the keys: + + - ``slug`` — URL path (e.g. ``"number/8880000111"``). + - ``name`` — display value (e.g. ``"+888 0000 111"``). + - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``. + - ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``. + + Use ``next_offset_id`` to paginate to the next page of results. + """ + + items: list[dict[str, Any]] + next_offset_id: str | None + + def __repr__(self) -> str: + return f"NumbersResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + + +@dataclass +class GiftsResult: + """Result of :meth:`FragmentClient.search_gifts`. + + Each dict in ``items`` has the keys: + + - ``slug`` — URL path (e.g. ``"gift/plushpepe-1821"``). + - ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``). + - ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places (e.g. ``"88888.00"``), or ``None``. + - ``date`` — ISO 8601 datetime of the sale/listing, or ``None``. + + Use ``next_offset`` to paginate to the next page of results. + """ + + items: list[dict[str, Any]] + next_offset: int | None + + def __repr__(self) -> str: + return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})" + + +__all__ = [ + "AdsRechargeResult", + "AdsTopupResult", + "GiftsResult", + "LoginCodeResult", + "NumbersResult", + "PremiumGiveawayResult", + "PremiumResult", + "StarsGiveawayResult", + "StarsResult", + "TerminateSessionsResult", + "UsernamesResult", + "WalletInfo", +] diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index 59d3693..a52cf8b 100644 --- a/pyfragment/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,18 +1,24 @@ from pyfragment.utils.decoder import clean_decode +from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code from pyfragment.utils.http import ( execute_transaction_request, - fragment_post, + fragment_request, get_fragment_hash, + make_headers, parse_json_response, ) from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", + "parse_auction_rows", + "parse_gift_items", + "parse_login_code", "execute_transaction_request", - "fragment_post", + "fragment_request", "get_account_info", "get_fragment_hash", + "make_headers", "parse_json_response", "process_transaction", ] diff --git a/pyfragment/utils/html.py b/pyfragment/utils/html.py new file mode 100644 index 0000000..f3a82e5 --- /dev/null +++ b/pyfragment/utils/html.py @@ -0,0 +1,161 @@ +import re +from typing import Any + +# Matches the login code inside a table-cell-value element. +CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<') +# Counts active session rows in the HTML table. +ROW_RE = re.compile(r"]") + +# Auction table row parsing +ROW_BLOCK_RE = re.compile(r']*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)', re.DOTALL) +HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"') +VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<') +PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<") +DATETIME_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>') +DATETIME_SHORT_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>') +# Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888") +NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$") + +# Gift grid item parsing +GRID_ITEM_RE = re.compile(r']*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)', re.DOTALL) +GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))') +GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<') +GRID_NUM_RE = re.compile(r'class="item-num">[^#]*#(\w+)<') +GRID_PRICE_RE = re.compile(r'class="[^"]*tm-grid-item-value[^"]*icon-ton[^"]*"[^>]*>\s*([0-9][^<]*?)\s*<') +GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<]+?)\s*<') +GRID_DATETIME_RE = re.compile(r']+datetime="([^"]+)"') + + +def parse_login_code(html: str) -> tuple[str | None, int]: + """Extract the pending login code and active session count from a Fragment numbers page HTML snippet. + + Args: + html: Raw HTML string returned by the Fragment API. + + Returns: + A tuple of ``(code, active_sessions)`` where ``code`` is ``None`` if no + pending code is present, and ``active_sessions`` is the number of ```` + rows found (each row represents one active session). + """ + match = CODE_RE.search(html) + code = match.group(1).strip() if match else None + active_sessions = len(ROW_RE.findall(html)) + return code, active_sessions + + +def parse_auction_rows(html: str) -> list[dict[str, Any]]: + """Parse Fragment marketplace HTML into structured item dicts. + + Extracts each ```` and returns a list of dicts + with the following keys: + + - ``slug`` — URL path segment (e.g. ``"username/durov"``). + - ``name`` — display value (e.g. ``"@durov"`` or ``"+888..."``) + - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), + or ``None`` if not listed. + - ``date`` — ISO 8601 datetime string: auction end date, sale date, or listing date, or ``None``. + + Returns: + List of item dicts, one per table row. + """ + items: list[dict[str, Any]] = [] + for row_match in ROW_BLOCK_RE.finditer(html): + row = row_match.group(1) + + href_m = HREF_RE.search(row) + if not href_m: + continue + slug = href_m.group(1).lstrip("/") # e.g. "username/durov" + + # All tm-value spans in the row — first is the display name + values = [m.group(1).strip() for m in VALUE_RE.finditer(row)] + name = values[0] if values else slug + + # Status: find the human-readable label from subsequent tm-value spans. + # Skip usernames (@), numeric-only values (prices like "150,492", phone numbers like "+888 0088 8888"). + status: str | None = None + for v in values[1:]: + if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v): + status = v + break + + # Price — look for icon-ton pattern, format as two decimal places + price_m = PRICE_RE.search(row) + price: str | None = None + if price_m: + raw_price = price_m.group(1).strip().replace(",", "") + try: + price = f"{float(raw_price):.2f}" + except ValueError: + price = raw_price + + # Datetime (ISO 8601) — auction end, sale date, or listing date. + time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row) + date: str | None = time_m.group(1) if time_m else None + + items.append( + { + "slug": slug, + "name": name, + "status": status, + "price": price, + "date": date, + } + ) + return items + + +def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]: + """Parse Fragment gifts grid HTML into structured item dicts. + + Extracts each ```` block and returns a list of dicts + with the following keys: + + - ``slug`` — URL path segment (e.g. ``"gift/plushpepe-1821"``). + - ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``). + - ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places, or ``None``. + - ``date`` — ISO 8601 datetime of the sale/listing, or ``None``. + + Returns: + Tuple of ``(items, next_offset)`` where ``next_offset`` is an integer + page offset from ``data-next-offset``, or ``None`` on the last page. + """ + items: list[dict[str, Any]] = [] + for item_match in GRID_ITEM_RE.finditer(html): + block = item_match.group(0) + + href_m = GRID_HREF_RE.search(block) + if not href_m: + continue + slug = href_m.group(1).lstrip("/") # e.g. "gift/plushpepe-1821" + + name_m = GRID_NAME_RE.search(block) + num_m = GRID_NUM_RE.search(block) + item_name = name_m.group(1).strip() if name_m else slug + item_num = f" #{num_m.group(1)}" if num_m else "" + name = f"{item_name}{item_num}" + + status_m = GRID_STATUS_RE.search(block) + status: str | None = status_m.group(1).strip() if status_m else None + + price_m = GRID_PRICE_RE.search(block) + price: str | None = None + if price_m: + raw_price = price_m.group(1).strip().replace(",", "") + try: + price = f"{float(raw_price):.2f}" + except ValueError: + price = raw_price + + time_m = GRID_DATETIME_RE.search(block) + date: str | None = time_m.group(1) if time_m else None + + items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date}) + + # Pagination offset from data-next-offset attribute + next_offset_m = re.search(r'data-next-offset="(\d+)"', html) + next_offset = int(next_offset_m.group(1)) if next_offset_m else None + + return items, next_offset diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py index cbdb6e9..d21702e 100644 --- a/pyfragment/utils/http.py +++ b/pyfragment/utils/http.py @@ -4,7 +4,11 @@ from typing import Any import httpx from pyfragment.types import FragmentPageError, ParseError, VerificationError -from pyfragment.types.constants import DEFAULT_TIMEOUT +from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL + + +def make_headers(page_url: str = FRAGMENT_BASE_URL) -> dict[str, str]: + return {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url} async def get_fragment_hash( @@ -40,7 +44,7 @@ async def get_fragment_hash( page_headers.update( { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "referer": "https://fragment.com/", + "referer": f"{FRAGMENT_BASE_URL}/", "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "upgrade-insecure-requests": "1", @@ -79,7 +83,7 @@ def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc -async def fragment_post( +async def fragment_request( session: httpx.AsyncClient, fragment_hash: str, headers: dict[str, str], @@ -101,7 +105,7 @@ async def fragment_post( Parsed API response as a dict. """ resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", + f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}", headers=headers, data=data, ) @@ -129,7 +133,7 @@ async def execute_transaction_request( VerificationError: If Fragment requires KYC verification. ParseError: If the response cannot be parsed. """ - transaction = await fragment_post(session, fragment_hash, headers, tx_data) + transaction = await fragment_request(session, fragment_hash, headers, tx_data) if transaction.get("need_verify"): raise VerificationError(VerificationError.KYC_REQUIRED) 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")) diff --git a/pyproject.toml b/pyproject.toml index c422aa6..753fb5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "pyfragment" -version = "2026.0.2" -description = "Python library for the Fragment.com API — purchase Telegram Stars, Premium, and top up TON Ads balance." +version = "2026.1.0" +description = "Async Python client for the Fragment API — a unified toolkit to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, run giveaways, manage anonymous numbers, and explore the marketplace for usernames, numbers, and gifts." readme = "README.md" license = { text = "MIT" } requires-python = ">=3.12" diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index 170fa1e..8a60ac3 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -1,4 +1,4 @@ -"""Tests for clean_decode() — BOC-encoded Fragment payloads decode to UTF-8.""" +"""Tests for clean_decode() — TON BOC payload decoding.""" import re @@ -23,6 +23,9 @@ PAYLOADS = [ ] +# Decode valid payload tests + + @pytest.mark.parametrize("payload", PAYLOADS) def test_decode_payload(payload: str) -> None: result = clean_decode(payload) @@ -31,6 +34,9 @@ def test_decode_payload(payload: str) -> None: assert all(ord(c) < 128 for c in result), f"non-ASCII chars in {result!r}" +# Edge case tests + + def test_empty_payload_returns_empty_string() -> None: assert clean_decode("") == "" diff --git a/tests/002_test_client.py b/tests/002_test_client.py index ccf10fe..16ced03 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -1,4 +1,4 @@ -"""Unit tests for FragmentClient — init validation and cookie parsing (no network calls).""" +"""Unit tests for FragmentClient — initialization, validation, and cookie parsing.""" import json @@ -6,15 +6,9 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import ConfigurationError, CookieError +from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED -VALID_SEED = "abandon " * 23 + "about" -VALID_API_KEY = "A" * 68 -VALID_COOKIES = { - "stel_ssid": "x", - "stel_dt": "x", - "stel_token": "x", - "stel_ton_token": "x", -} +# Client init tests def test_valid_init() -> None: @@ -24,6 +18,9 @@ def test_valid_init() -> None: assert client.wallet_version == "V5R1" +# Wallet version tests + + def test_wallet_version_v4r2() -> None: client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V4R2") assert client.wallet_version == "V4R2" @@ -34,6 +31,14 @@ def test_wallet_version_is_case_insensitive() -> None: assert client.wallet_version == "V5R1" +def test_unsupported_wallet_version_raises() -> None: + with pytest.raises(ConfigurationError): + FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2") + + +# Seed and mnemonic validation tests + + def test_missing_seed_raises() -> None: with pytest.raises(ConfigurationError): FragmentClient(seed="", api_key=VALID_API_KEY, cookies=VALID_COOKIES) @@ -44,14 +49,33 @@ def test_whitespace_only_seed_raises() -> None: FragmentClient(seed=" ", api_key=VALID_API_KEY, cookies=VALID_COOKIES) +def test_invalid_mnemonic_length_raises() -> None: + bad_seed = " ".join(["word"] * 23) + with pytest.raises(ConfigurationError): + FragmentClient(seed=bad_seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES) + + +def test_valid_mnemonic_lengths() -> None: + for length in (12, 18, 24): + seed = " ".join(["abandon"] * (length - 1) + ["about"]) + client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES) + assert len(client.seed.split()) == length + + +# API key validation tests + + def test_missing_api_key_raises() -> None: with pytest.raises(ConfigurationError): FragmentClient(seed=VALID_SEED, api_key="", cookies=VALID_COOKIES) -def test_unsupported_wallet_version_raises() -> None: +def test_short_api_key_raises() -> None: with pytest.raises(ConfigurationError): - FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES, wallet_version="V3R2") + FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES) + + +# Cookie validation tests def test_cookies_as_json_string() -> None: @@ -81,24 +105,6 @@ def test_whitespace_cookie_value_raises() -> None: FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=bad) -def test_invalid_mnemonic_length_raises() -> None: - bad_seed = " ".join(["word"] * 23) - with pytest.raises(ConfigurationError): - FragmentClient(seed=bad_seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES) - - -def test_valid_mnemonic_lengths() -> None: - for length in (12, 18, 24): - seed = " ".join(["abandon"] * (length - 1) + ["about"]) - client = FragmentClient(seed=seed, api_key=VALID_API_KEY, cookies=VALID_COOKIES) - assert len(client.seed.split()) == length - - -def test_short_api_key_raises() -> None: - with pytest.raises(ConfigurationError): - FragmentClient(seed=VALID_SEED, api_key="A" * 42, cookies=VALID_COOKIES) - - def test_repr() -> None: client = FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) r = repr(client) diff --git a/tests/003_test_hash.py b/tests/003_test_hash.py index bb883ba..d11f05c 100644 --- a/tests/003_test_hash.py +++ b/tests/003_test_hash.py @@ -1,5 +1,4 @@ -"""Tests for get_fragment_hash() — fetches a valid lowercase hex hash -from the fragment.com/stars/buy page source.""" +"""Tests for get_fragment_hash() — API hash extraction from Fragment page source.""" import re diff --git a/tests/004_test_balance.py b/tests/004_test_balance.py index a784300..185ba30 100644 --- a/tests/004_test_balance.py +++ b/tests/004_test_balance.py @@ -1,14 +1,19 @@ -"""Unit tests for process_transaction() — balance checks before broadcast.""" +"""Unit tests for process_transaction() — balance validation and broadcast retry logic.""" from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, patch import pytest +from tonutils.exceptions import ProviderResponseError from pyfragment.types import TransactionError, WalletError from pyfragment.utils.wallet import process_transaction +from tests.shared import VALID_SEED + + +def _provider_error(code: int, message: str = "error") -> ProviderResponseError: + return ProviderResponseError(code=code, message=message, endpoint="api.tonapi.io") -VALID_SEED = "abandon " * 23 + "about" TRANSACTION_DATA = { "transaction": { @@ -51,6 +56,9 @@ def _patch_wallet(wallet: MagicMock): yield +# Balance threshold tests + + @pytest.mark.asyncio async def test_sufficient_balance_broadcasts() -> None: wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON @@ -85,7 +93,41 @@ async def test_one_nanoton_below_minimum_raises() -> None: await process_transaction(_make_client(), TRANSACTION_DATA) +# Error handling tests + + @pytest.mark.asyncio async def test_invalid_payload_raises() -> None: with pytest.raises(TransactionError): await process_transaction(_make_client(), {"transaction": {}}) + + +@pytest.mark.asyncio +async def test_balance_check_failed_raises_wallet_error() -> None: + wallet = _make_wallet(balance_nanotons=1_000_000_000) + wallet.refresh = AsyncMock(side_effect=RuntimeError("network timeout")) + with _patch_wallet(wallet): + with pytest.raises(WalletError, match="balance"): + await process_transaction(_make_client(), TRANSACTION_DATA) + wallet.transfer.assert_not_called() + + +@pytest.mark.asyncio +async def test_rate_limit_retries_and_succeeds() -> None: + wallet = _make_wallet(balance_nanotons=1_000_000_000) + wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")]) + with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""): + result = await process_transaction(_make_client(), TRANSACTION_DATA) + assert result == "abc123" + assert wallet.transfer.call_count == 2 + + +@pytest.mark.asyncio +async def test_duplicate_seqno_raises_after_retries() -> None: + wallet = _make_wallet(balance_nanotons=1_000_000_000) + err = _provider_error(406, "Duplicate msg_seqno") + wallet.transfer = AsyncMock(side_effect=[err, err, err]) + with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value=""): + with pytest.raises(TransactionError, match="seqno"): + await process_transaction(_make_client(), TRANSACTION_DATA) + assert wallet.transfer.call_count == 3 diff --git a/tests/005_test_methods.py b/tests/005_test_methods.py deleted file mode 100644 index 563e023..0000000 --- a/tests/005_test_methods.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Unit tests for method-level input validation — no network calls.""" - -import pytest - -from pyfragment import FragmentClient -from pyfragment.types import ConfigurationError - -VALID_SEED = "abandon " * 23 + "about" -VALID_API_KEY = "A" * 68 -VALID_COOKIES = { - "stel_ssid": "x", - "stel_dt": "x", - "stel_token": "x", - "stel_ton_token": "x", -} - - -@pytest.fixture -def client() -> FragmentClient: - return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) - - -@pytest.mark.asyncio -async def test_purchase_premium_invalid_months(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.purchase_premium("@user", months=5) - - -@pytest.mark.asyncio -async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.purchase_stars("@user", amount=49) - - -@pytest.mark.asyncio -async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.purchase_stars("@user", amount=1_000_001) - - -@pytest.mark.asyncio -async def test_purchase_stars_float_amount(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type] - - -@pytest.mark.asyncio -async def test_topup_ton_amount_zero(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.topup_ton("@user", amount=0) - - -@pytest.mark.asyncio -async def test_topup_ton_amount_too_high(client: FragmentClient) -> None: - with pytest.raises(ConfigurationError): - await client.topup_ton("@user", amount=1_000_000_001) diff --git a/tests/005_test_stars.py b/tests/005_test_stars.py new file mode 100644 index 0000000..0a87f94 --- /dev/null +++ b/tests/005_test_stars.py @@ -0,0 +1,137 @@ +"""Unit tests for Stars methods — purchase_stars and giveaway_stars.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError +from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH + +# Stars purchase validation tests + + +@pytest.mark.asyncio +async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.purchase_stars("@user", amount=49) + + +@pytest.mark.asyncio +async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.purchase_stars("@user", amount=1_000_001) + + +@pytest.mark.asyncio +async def test_purchase_stars_float_amount(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type] + + +# Stars purchase mocked tests + + +@pytest.mark.asyncio +async def test_purchase_stars_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.purchase_stars.fragment_request", + AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]), + ), + patch("pyfragment.methods.purchase_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.purchase_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.purchase_stars("@user", amount=500) + + assert isinstance(result, StarsResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "@user" + assert result.amount == 500 + + +@pytest.mark.asyncio +async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.purchase_stars.fragment_request", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.purchase_stars("@ghost", amount=500) + + +# Stars giveaway validation tests + + +@pytest.mark.asyncio +async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=0, amount=500) + + +@pytest.mark.asyncio +async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=6, amount=500) + + +@pytest.mark.asyncio +async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=1, amount=499) + + +@pytest.mark.asyncio +async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=1, amount=1_000_001) + + +@pytest.mark.asyncio +async def test_giveaway_stars_float_winners(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=1.5, amount=500) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_giveaway_stars_float_amount(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_stars("@channel", winners=1, amount=500.5) # type: ignore[arg-type] + + +# Stars giveaway mocked tests + + +@pytest.mark.asyncio +async def test_giveaway_stars_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.giveaway_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.giveaway_stars.fragment_request", + AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]), + ), + patch("pyfragment.methods.giveaway_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.giveaway_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.giveaway_stars("@channel", winners=3, amount=1000) + + assert isinstance(result, StarsGiveawayResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.channel == "@channel" + assert result.winners == 3 + assert result.amount == 1000 + + +@pytest.mark.asyncio +async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.giveaway_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.giveaway_stars.fragment_request", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.giveaway_stars("@ghost", winners=1, amount=500) diff --git a/tests/006_test_methods_mock.py b/tests/006_test_methods_mock.py deleted file mode 100644 index f86c69e..0000000 --- a/tests/006_test_methods_mock.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Tests for purchase methods with all network calls mocked.""" - -from unittest.mock import AsyncMock, patch - -import pytest - -from pyfragment import FragmentClient -from pyfragment.types import AdsTopupResult, PremiumResult, StarsResult, UserNotFoundError - -VALID_SEED = "abandon " * 23 + "about" -VALID_API_KEY = "A" * 68 -VALID_COOKIES = { - "stel_ssid": "x", - "stel_dt": "x", - "stel_token": "x", - "stel_ton_token": "x", -} -FAKE_HASH = "abc123" -FAKE_RECIPIENT = "recipient_token" -FAKE_REQ_ID = "req_42" -FAKE_TX_HASH = "deadbeef" * 8 -FAKE_ACCOUNT = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="} -FAKE_TRANSACTION = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}} - - -@pytest.fixture -def client() -> FragmentClient: - return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) - - -@pytest.mark.asyncio -async def test_purchase_stars_success(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.stars.fragment_post", - AsyncMock( - side_effect=[ - {"found": {"recipient": FAKE_RECIPIENT}}, # searchStarsRecipient - {"req_id": FAKE_REQ_ID}, # initBuyStarsRequest - ] - ), - ), - patch("pyfragment.methods.stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), - patch("pyfragment.methods.stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), - ): - result = await client.purchase_stars("testuser", amount=100) - - assert isinstance(result, StarsResult) - assert result.transaction_id == FAKE_TX_HASH - assert result.username == "testuser" - assert result.stars == 100 - - -@pytest.mark.asyncio -async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.stars.fragment_post", AsyncMock(return_value={"found": {}})), - ): - with pytest.raises(UserNotFoundError): - await client.purchase_stars("ghost", amount=100) - - -@pytest.mark.asyncio -async def test_purchase_premium_success(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.premium.fragment_post", - AsyncMock( - side_effect=[ - {"found": {"recipient": FAKE_RECIPIENT}}, # searchPremiumGiftRecipient - {}, # updatePremiumState - {"req_id": FAKE_REQ_ID}, # initGiftPremiumRequest - ] - ), - ), - patch("pyfragment.methods.premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), - patch("pyfragment.methods.premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), - ): - result = await client.purchase_premium("testuser", months=6) - - assert isinstance(result, PremiumResult) - assert result.transaction_id == FAKE_TX_HASH - assert result.username == "testuser" - assert result.months == 6 - - -@pytest.mark.asyncio -async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.premium.fragment_post", AsyncMock(return_value={"found": {}})), - ): - with pytest.raises(UserNotFoundError): - await client.purchase_premium("ghost", months=3) - - -@pytest.mark.asyncio -async def test_topup_ton_success(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.ton.fragment_post", - AsyncMock( - side_effect=[ - {}, # updateAdsTopupState - {"found": {"recipient": FAKE_RECIPIENT}}, # searchAdsTopupRecipient - {"req_id": FAKE_REQ_ID}, # initAdsTopupRequest - ] - ), - ), - patch("pyfragment.methods.ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), - patch("pyfragment.methods.ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), - ): - result = await client.topup_ton("testuser", amount=10) - - assert isinstance(result, AdsTopupResult) - assert result.transaction_id == FAKE_TX_HASH - assert result.username == "testuser" - assert result.amount == 10 - - -@pytest.mark.asyncio -async def test_topup_ton_user_not_found(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.ton.fragment_post", - AsyncMock( - side_effect=[ - {}, # updateAdsTopupState - {"found": {}}, # searchAdsTopupRecipient → not found - ] - ), - ), - ): - with pytest.raises(UserNotFoundError): - await client.topup_ton("ghost", amount=10) diff --git a/tests/006_test_premium.py b/tests/006_test_premium.py new file mode 100644 index 0000000..0a0b11e --- /dev/null +++ b/tests/006_test_premium.py @@ -0,0 +1,125 @@ +"""Unit tests for Premium methods — purchase_premium and giveaway_premium.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError +from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH + +# Premium purchase validation tests + + +@pytest.mark.asyncio +async def test_purchase_premium_invalid_months(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.purchase_premium("@user", months=5) + + +@pytest.mark.asyncio +async def test_purchase_premium_months_zero(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.purchase_premium("@user", months=0) + + +# Premium purchase mocked tests + + +@pytest.mark.asyncio +async def test_purchase_premium_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.purchase_premium.fragment_request", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updatePremiumState + {"req_id": FAKE_REQ_ID}, + ] + ), + ), + patch("pyfragment.methods.purchase_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.purchase_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.purchase_premium("@user", months=3) + + assert isinstance(result, PremiumResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "@user" + assert result.amount == 3 + + +@pytest.mark.asyncio +async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.purchase_premium.fragment_request", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.purchase_premium("@ghost", months=3) + + +# Premium giveaway validation tests + + +@pytest.mark.asyncio +async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_premium("@channel", winners=0, months=3) + + +@pytest.mark.asyncio +async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_premium("@channel", winners=24_001, months=3) + + +@pytest.mark.asyncio +async def test_giveaway_premium_float_winners(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_premium("@channel", winners=2.5, months=3) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.giveaway_premium("@channel", winners=10, months=5) + + +# Premium giveaway mocked tests + + +@pytest.mark.asyncio +async def test_giveaway_premium_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.giveaway_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.giveaway_premium.fragment_request", + AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]), + ), + patch("pyfragment.methods.giveaway_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.giveaway_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.giveaway_premium("@channel", winners=10, months=3) + + assert isinstance(result, PremiumGiveawayResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.channel == "@channel" + assert result.winners == 10 + assert result.amount == 3 + + +@pytest.mark.asyncio +async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.giveaway_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.giveaway_premium.fragment_request", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.giveaway_premium("@ghost", winners=1, months=3) diff --git a/tests/007_test_topup.py b/tests/007_test_topup.py new file mode 100644 index 0000000..2b491bc --- /dev/null +++ b/tests/007_test_topup.py @@ -0,0 +1,72 @@ +"""Unit tests for topup_ton — TON Ads balance top-up.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError +from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH + +# Topup TON validation tests + + +@pytest.mark.asyncio +async def test_topup_ton_amount_zero(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.topup_ton("@user", amount=0) + + +@pytest.mark.asyncio +async def test_topup_ton_amount_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.topup_ton("@user", amount=1_000_000_001) + + +@pytest.mark.asyncio +async def test_topup_ton_float_amount(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.topup_ton("@user", amount=1.5) # type: ignore[arg-type] + + +# Topup TON mocked tests + + +@pytest.mark.asyncio +async def test_topup_ton_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.topup_ton.fragment_request", + AsyncMock( + side_effect=[ + {}, # updateAdsTopupState + {"found": {"recipient": FAKE_RECIPIENT}}, + {"req_id": FAKE_REQ_ID}, + ] + ), + ), + patch("pyfragment.methods.topup_ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.topup_ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.topup_ton("@user", amount=10) + + assert isinstance(result, AdsTopupResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "@user" + assert result.amount == 10 + + +@pytest.mark.asyncio +async def test_topup_ton_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.topup_ton.fragment_request", + AsyncMock(side_effect=[{}, {"found": {}}]), + ), + ): + with pytest.raises(UserNotFoundError): + await client.topup_ton("@ghost", amount=10) diff --git a/tests/008_test_wallet.py b/tests/008_test_wallet.py new file mode 100644 index 0000000..d13c081 --- /dev/null +++ b/tests/008_test_wallet.py @@ -0,0 +1,56 @@ +"""Unit tests for get_wallet() — wallet address and TON balance lookup.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from pyfragment import FragmentClient, WalletInfo +from tests.shared import FAKE_ADDRESS, FAKE_BALANCE_NANOTON + +# Wallet mocked tests + + +@pytest.mark.asyncio +async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None: + mock_wallet = MagicMock() + mock_wallet.refresh = AsyncMock() + mock_wallet.balance = FAKE_BALANCE_NANOTON + mock_wallet.state = MagicMock(value="active") + mock_wallet.address.to_str.return_value = FAKE_ADDRESS + + with ( + patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, + patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, + ): + mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) + mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) + mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None) + + result = await client.get_wallet() + + assert isinstance(result, WalletInfo) + assert result.address == FAKE_ADDRESS + assert result.state == "active" + assert result.balance == round(FAKE_BALANCE_NANOTON / 1_000_000_000, 4) + + +@pytest.mark.asyncio +async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None: + mock_wallet = MagicMock() + mock_wallet.refresh = AsyncMock() + mock_wallet.balance = 0 + mock_wallet.state = MagicMock(value="uninit") + mock_wallet.address.to_str.return_value = FAKE_ADDRESS + + with ( + patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, + patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, + ): + mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) + mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) + mock_classes["V5R1"].from_mnemonic.return_value = (mock_wallet, MagicMock(), None, None) + + result = await client.get_wallet() + + assert result.balance == 0.0 + assert result.state == "uninit" diff --git a/tests/009_test_call.py b/tests/009_test_call.py new file mode 100644 index 0000000..14b6b59 --- /dev/null +++ b/tests/009_test_call.py @@ -0,0 +1,63 @@ +"""Unit tests for FragmentClient.call() — raw Fragment API access.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from tests.shared import FAKE_HASH, FAKE_RESPONSE + +# client.call() mocked tests + + +@pytest.mark.asyncio +async def test_call_returns_api_response(client: FragmentClient) -> None: + with ( + patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), + ): + result = await client.call("anyMethod", {"key": "value"}) + + assert result == FAKE_RESPONSE + + +@pytest.mark.asyncio +async def test_call_default_page_url(client: FragmentClient) -> None: + """call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL).""" + with ( + patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), + ): + result = await client.call("anyMethod") + + assert result == FAKE_RESPONSE + + +@pytest.mark.asyncio +async def test_call_no_data(client: FragmentClient) -> None: + """call() with no extra data passes only the method field.""" + mock_request = AsyncMock(return_value={}) + + with ( + patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.client.fragment_request", mock_request), + ): + await client.call("anyMethod") + + _, _, _, sent_data = mock_request.call_args.args + assert sent_data == {"method": "anyMethod"} + + +@pytest.mark.asyncio +async def test_call_merges_extra_data(client: FragmentClient) -> None: + """call() merges caller-supplied data with the method field.""" + mock_request = AsyncMock(return_value={}) + + with ( + patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.client.fragment_request", mock_request), + ): + await client.call("anyMethod", {"key": "value", "num": 7}) + + _, _, _, sent_data = mock_request.call_args.args + assert sent_data == {"method": "anyMethod", "key": "value", "num": 7} diff --git a/tests/010_test_number.py b/tests/010_test_number.py new file mode 100644 index 0000000..bdcdb02 --- /dev/null +++ b/tests/010_test_number.py @@ -0,0 +1,163 @@ +"""Unit tests for anonymous number methods — login codes and session management.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult +from tests.shared import FAKE_HASH, FAKE_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH + +# get_login_code tests + + +@pytest.mark.asyncio +async def test_get_login_code_returns_code(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.anonymous_number.fragment_request", + AsyncMock(return_value={"html": FAKE_HTML_WITH_CODE}), + ), + ): + result = await client.get_login_code("+1234567890") + + assert isinstance(result, LoginCodeResult) + assert result.number == "+1234567890" + assert result.code == "12345" + assert result.active_sessions == 2 # 2 in the HTML + + +@pytest.mark.asyncio +async def test_get_login_code_no_pending_code(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.anonymous_number.fragment_request", + AsyncMock(return_value={"html": FAKE_HTML_NO_CODE}), + ), + ): + result = await client.get_login_code("1234567890") + + assert result.code is None + assert result.active_sessions == 1 + + +@pytest.mark.asyncio +async def test_get_login_code_no_html_returns_none(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})), + ): + result = await client.get_login_code("+1234567890") + + assert result.code is None + assert result.active_sessions == 0 + + +# terminate_sessions tests + + +@pytest.mark.asyncio +async def test_terminate_sessions_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.anonymous_number.fragment_request", + AsyncMock( + side_effect=[ + {"terminate_hash": FAKE_TERMINATE_HASH}, # step 1: confirmation + {"msg": "All sessions terminated"}, # step 2: confirmed + ] + ), + ), + ): + result = await client.terminate_sessions("+1234567890") + + assert isinstance(result, TerminateSessionsResult) + assert result.number == "+1234567890" + assert result.message == "All sessions terminated" + + +@pytest.mark.asyncio +async def test_terminate_sessions_not_owned_raises(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})), + ): + with pytest.raises(AnonymousNumberError, match="not associated"): + await client.terminate_sessions("+1234567890") + + +@pytest.mark.asyncio +async def test_terminate_sessions_api_error_raises(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.anonymous_number.fragment_request", + AsyncMock(return_value={"error": "SESSION_ALREADY_TERMINATED"}), + ), + ): + with pytest.raises(AnonymousNumberError, match="SESSION_ALREADY_TERMINATED"): + await client.terminate_sessions("+1234567890") + + +@pytest.mark.asyncio +async def test_terminate_sessions_confirm_error_raises(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.anonymous_number.fragment_request", + AsyncMock( + side_effect=[ + {"terminate_hash": FAKE_TERMINATE_HASH}, + {"error": "INTERNAL_ERROR"}, + ] + ), + ), + ): + with pytest.raises(AnonymousNumberError, match="INTERNAL_ERROR"): + await client.terminate_sessions("+1234567890") + + +# toggle_login_codes tests + + +@pytest.mark.asyncio +async def test_toggle_login_codes_enable(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={"ok": True})) as mock_req, + ): + await client.toggle_login_codes("+1234567890", can_receive=True) + + call_data = mock_req.call_args[0][3] + assert call_data["can_receive"] == 1 + assert call_data["method"] == "toggleLoginCodes" + + +@pytest.mark.asyncio +async def test_toggle_login_codes_disable(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={"ok": True})) as mock_req, + ): + await client.toggle_login_codes("+1234567890", can_receive=False) + + call_data = mock_req.call_args[0][3] + assert call_data["can_receive"] == 0 + + +# strip_plus tests + + +@pytest.mark.asyncio +async def test_get_login_code_strips_plus(client: FragmentClient) -> None: + """Number passed with '+' is stripped before the API call.""" + with ( + patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})) as mock_req, + ): + await client.get_login_code("+1234567890") + + call_data = mock_req.call_args[0][3] + assert call_data["number"] == "1234567890" diff --git a/tests/011_test_recharge_ads.py b/tests/011_test_recharge_ads.py new file mode 100644 index 0000000..e1bf593 --- /dev/null +++ b/tests/011_test_recharge_ads.py @@ -0,0 +1,56 @@ +"""Unit tests for recharge_ads — self-service Telegram Ads recharge.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import AdsRechargeResult, ConfigurationError +from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_HASH, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH + +# recharge_ads validation tests + + +@pytest.mark.asyncio +async def test_recharge_ads_amount_zero(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0) + + +@pytest.mark.asyncio +async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001) + + +@pytest.mark.asyncio +async def test_recharge_ads_float_amount(client: FragmentClient) -> None: + with pytest.raises(ConfigurationError): + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=5.5) # type: ignore[arg-type] + + +# recharge_ads mocked tests + + +@pytest.mark.asyncio +async def test_recharge_ads_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.recharge_ads.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.recharge_ads.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.recharge_ads.fragment_request", + AsyncMock( + side_effect=[ + {}, # updateAdsState + {"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest + ] + ), + ), + patch("pyfragment.methods.recharge_ads.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.recharge_ads.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10) + + assert isinstance(result, AdsRechargeResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.amount == 10 diff --git a/tests/012_test_usernames.py b/tests/012_test_usernames.py new file mode 100644 index 0000000..7f482ff --- /dev/null +++ b/tests/012_test_usernames.py @@ -0,0 +1,111 @@ +"""Unit tests for search_usernames — Fragment marketplace username search.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import UsernamesResult +from tests.shared import FAKE_HASH + +FAKE_HTML = """ + + +
@coolname
+
On auction
+
+
5
+ + +
On auction
+ +""" + + +@pytest.mark.asyncio +async def test_search_usernames_basic(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ), + ): + result = await client.search_usernames("coolname") + + assert isinstance(result, UsernamesResult) + assert len(result.items) == 1 + assert result.items[0]["slug"] == "username/coolname" + assert result.items[0]["name"] == "@coolname" + assert result.items[0]["date"] == "2026-06-01T12:00:00+00:00" + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_usernames_empty_html(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True}), + ), + ): + result = await client.search_usernames("zzz_no_results") + + assert isinstance(result, UsernamesResult) + assert result.items == [] + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_usernames("durov", sort="price_desc", filter="auction") + + assert isinstance(result, UsernamesResult) + call_data = mock_request.call_args[0][3] + assert call_data["type"] == "usernames" + assert call_data["method"] == "searchAuctions" + assert call_data["sort"] == "price_desc" + assert call_data["filter"] == "auction" + assert call_data["query"] == "durov" + + +@pytest.mark.asyncio +async def test_search_usernames_with_offset_id(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}), + ) as mock_request, + ): + result = await client.search_usernames("durov", offset_id="offset_10") + + assert isinstance(result, UsernamesResult) + assert result.next_offset_id == "offset_99" + call_data = mock_request.call_args[0][3] + assert call_data["offset_id"] == "offset_10" + + +@pytest.mark.asyncio +async def test_search_usernames_default_query(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_usernames() + + assert isinstance(result, UsernamesResult) + call_data = mock_request.call_args[0][3] + assert call_data["query"] == "" + assert call_data["type"] == "usernames" diff --git a/tests/013_test_numbers.py b/tests/013_test_numbers.py new file mode 100644 index 0000000..62d6fd5 --- /dev/null +++ b/tests/013_test_numbers.py @@ -0,0 +1,111 @@ +"""Unit tests for search_numbers — Fragment marketplace number search.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import NumbersResult +from tests.shared import FAKE_HASH + +FAKE_HTML = """ + + +
+888 0000 888
+
For sale
+
+
150
+ + +
For sale
+ +""" + + +@pytest.mark.asyncio +async def test_search_numbers_basic(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ), + ): + result = await client.search_numbers("888") + + assert isinstance(result, NumbersResult) + assert len(result.items) == 1 + assert result.items[0]["slug"] == "number/8880000888" + assert result.items[0]["name"] == "+888 0000 888" + assert result.items[0]["date"] == "2026-05-15T10:00:00+00:00" + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_numbers_empty_html(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True}), + ), + ): + result = await client.search_numbers("zzz_no_results") + + assert isinstance(result, NumbersResult) + assert result.items == [] + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_numbers("888", sort="price_asc", filter="sale") + + assert isinstance(result, NumbersResult) + call_data = mock_request.call_args[0][3] + assert call_data["type"] == "numbers" + assert call_data["method"] == "searchAuctions" + assert call_data["sort"] == "price_asc" + assert call_data["filter"] == "sale" + assert call_data["query"] == "888" + + +@pytest.mark.asyncio +async def test_search_numbers_with_offset_id(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"}), + ) as mock_request, + ): + result = await client.search_numbers("888", offset_id="offset_50") + + assert isinstance(result, NumbersResult) + assert result.next_offset_id == "offset_50" + call_data = mock_request.call_args[0][3] + assert call_data["offset_id"] == "offset_50" + + +@pytest.mark.asyncio +async def test_search_numbers_default_query(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_numbers() + + assert isinstance(result, NumbersResult) + call_data = mock_request.call_args[0][3] + assert call_data["query"] == "" + assert call_data["type"] == "numbers" diff --git a/tests/014_test_gifts.py b/tests/014_test_gifts.py new file mode 100644 index 0000000..475fb00 --- /dev/null +++ b/tests/014_test_gifts.py @@ -0,0 +1,188 @@ +"""Unit tests for search_gifts — Fragment gifts marketplace search.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import GiftsResult +from tests.shared import FAKE_HASH + +FAKE_GIFTS_HTML = """ + +""" + + +@pytest.mark.asyncio +async def test_search_gifts_basic(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ), + ): + result = await client.search_gifts() + + assert isinstance(result, GiftsResult) + assert len(result.items) == 2 + assert result.items[0]["slug"] == "gift/plushpepe-1821" + assert result.items[0]["name"] == "Plush Pepe #1821" + assert result.items[0]["status"] == "Sold" + assert result.items[0]["price"] == "88888.00" + assert result.items[0]["date"] == "2026-02-05T14:41:27+00:00" + assert result.items[1]["slug"] == "gift/swisswatch-7799" + assert result.items[1]["price"] == "13588.00" + assert result.next_offset == 60 + + +@pytest.mark.asyncio +async def test_search_gifts_empty(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True}), + ), + ): + result = await client.search_gifts(query="zzz_no_results") + + assert isinstance(result, GiftsResult) + assert result.items == [] + assert result.next_offset is None + + +@pytest.mark.asyncio +async def test_search_gifts_with_collection_and_sort(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts(collection="plushpepe", sort="price_desc", filter="sold") + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["collection"] == "plushpepe" + assert call_data["sort"] == "price_desc" + assert call_data["filter"] == "sold" + assert call_data["type"] == "gifts" + assert call_data["method"] == "searchAuctions" + + +@pytest.mark.asyncio +async def test_search_gifts_with_offset(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts(offset=60) + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["offset"] == 60 + + +@pytest.mark.asyncio +async def test_search_gifts_with_view(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts(collection="artisanbrick", view="Model") + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["view"] == "Model" + assert call_data["collection"] == "artisanbrick" + + +@pytest.mark.asyncio +async def test_search_gifts_with_attr(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts( + collection="artisanbrick", + sort="listed", + filter="auction", + attr={ + "Model": ["Delicate Wash", "Foosball", "Chocolate"], + "Backdrop": ["Celtic Blue", "Carrot Juice", "Orange"], + "Symbol": ["Crystal Ball", "Tetsubin", "Acorn"], + }, + ) + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["attr[Model]"] == ["Delicate Wash", "Foosball", "Chocolate"] + assert call_data["attr[Backdrop]"] == ["Celtic Blue", "Carrot Juice", "Orange"] + assert call_data["attr[Symbol]"] == ["Crystal Ball", "Tetsubin", "Acorn"] + assert call_data["collection"] == "artisanbrick" + assert call_data["sort"] == "listed" + assert call_data["filter"] == "auction" + assert call_data["type"] == "gifts" + + +@pytest.mark.asyncio +async def test_search_gifts_attr_not_in_data_when_none(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts() + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert "view" not in call_data + assert not any(k.startswith("attr[") for k in call_data) diff --git a/tests/conftest.py b/tests/conftest.py index 68c5e75..a05bfd4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,9 @@ import os import pytest +from pyfragment import FragmentClient +from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED + @pytest.fixture def cookies(): @@ -14,3 +17,9 @@ def cookies(): return json.loads(raw) except Exception as exc: pytest.skip(f"Cookies unavailable — {exc}") + + +@pytest.fixture +def client() -> FragmentClient: + """Pre-built FragmentClient with dummy credentials.""" + return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) diff --git a/tests/shared.py b/tests/shared.py new file mode 100644 index 0000000..1ce6be4 --- /dev/null +++ b/tests/shared.py @@ -0,0 +1,55 @@ +"""Shared test constants for the pyfragment test suite. + +pyfragment is an async Python client for the Fragment API — a unified toolkit +to manage Telegram assets: purchase Stars and Premium, top up TON and Ads balances, +run giveaways, manage anonymous numbers, and explore the marketplace for usernames, +numbers, and gifts. +""" + +from typing import Any + +# Credentials and config +VALID_SEED: str = "abandon " * 23 + "about" +VALID_API_KEY: str = "A" * 68 +VALID_COOKIES: dict[str, str] = { + "stel_ssid": "x", + "stel_dt": "x", + "stel_token": "x", + "stel_ton_token": "x", +} + +# Generic test data +FAKE_HASH: str = "abc123" +FAKE_RECIPIENT: str = "recipient_token" +FAKE_REQ_ID: str = "req_42" +FAKE_TX_HASH: str = "deadbeef" * 8 +FAKE_ACCOUNT: dict[str, Any] = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="} +FAKE_TRANSACTION: dict[str, Any] = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}} + +# client.call() +FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}} + +# get_wallet() +FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5" +FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON + +# recharge_ads +FAKE_ADS_ACCOUNT: str = "@mychannel" + +# Revenue withdrawals +FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +FAKE_REVENUE_TX: str = "revenue_tx_abc123" + +# Anonymous number +FAKE_HTML_WITH_CODE: str = """ + + + + + + + +
12345
session data
+""" +FAKE_HTML_NO_CODE: str = "
no code here
" +FAKE_TERMINATE_HASH: str = "terminate_hash_abc123"