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 @@
- 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.
[](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"