From bae2e1b4000e24516342eccc123e359590f7316d Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Wed, 25 Mar 2026 18:33:47 +0200 Subject: [PATCH 01/12] tests: add section comments to all test files --- tests/003_test_hash.py | 3 +++ tests/012_test_usernames.py | 3 +++ tests/013_test_numbers.py | 3 +++ tests/014_test_gifts.py | 3 +++ 4 files changed, 12 insertions(+) diff --git a/tests/003_test_hash.py b/tests/003_test_hash.py index d11f05c..bd87682 100644 --- a/tests/003_test_hash.py +++ b/tests/003_test_hash.py @@ -8,6 +8,9 @@ from pyfragment.types.constants import BASE_HEADERS, STARS_PAGE from pyfragment.utils import get_fragment_hash +# Hash integration tests + + @pytest.mark.asyncio async def test_hash_is_valid_hex(cookies: dict) -> None: result = await get_fragment_hash(cookies, BASE_HEADERS, STARS_PAGE) diff --git a/tests/012_test_usernames.py b/tests/012_test_usernames.py index 7f482ff..97ef2fe 100644 --- a/tests/012_test_usernames.py +++ b/tests/012_test_usernames.py @@ -22,6 +22,9 @@ FAKE_HTML = """ """ +# search_usernames mocked tests + + @pytest.mark.asyncio async def test_search_usernames_basic(client: FragmentClient) -> None: with ( diff --git a/tests/013_test_numbers.py b/tests/013_test_numbers.py index 62d6fd5..daa1293 100644 --- a/tests/013_test_numbers.py +++ b/tests/013_test_numbers.py @@ -22,6 +22,9 @@ FAKE_HTML = """ """ +# search_numbers mocked tests + + @pytest.mark.asyncio async def test_search_numbers_basic(client: FragmentClient) -> None: with ( diff --git a/tests/014_test_gifts.py b/tests/014_test_gifts.py index 475fb00..fd6e72a 100644 --- a/tests/014_test_gifts.py +++ b/tests/014_test_gifts.py @@ -48,6 +48,9 @@ FAKE_GIFTS_HTML = """ """ +# search_gifts mocked tests + + @pytest.mark.asyncio async def test_search_gifts_basic(client: FragmentClient) -> None: with ( From d4ac44f6986fdec42ce420575c5778319a0148fc Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Wed, 25 Mar 2026 18:35:09 +0200 Subject: [PATCH 02/12] style: remove unnecessary blank line in test_hash.py --- tests/003_test_hash.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/003_test_hash.py b/tests/003_test_hash.py index bd87682..2b72758 100644 --- a/tests/003_test_hash.py +++ b/tests/003_test_hash.py @@ -7,7 +7,6 @@ import pytest from pyfragment.types.constants import BASE_HEADERS, STARS_PAGE from pyfragment.utils import get_fragment_hash - # Hash integration tests From 503baa9a942e5ff8e7dd0b0e42ea666f5bd70332 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 7 Apr 2026 00:52:39 +0300 Subject: [PATCH 03/12] ci: update test job to use matrix strategy for multiple Python versions and switch to conda setup fix: specify type for headers parameter in execute_transaction_request fix: specify type for transaction_data parameter in process_transaction deps: update tonutils dependency version to 2.0.5 --- .github/workflows/ci.yml | 25 ++++++++++++++++++------- pyfragment/utils/http.py | 2 +- pyfragment/utils/wallet.py | 2 +- pyproject.toml | 2 +- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7696bb7..1dd09d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,21 +27,32 @@ jobs: - run: ruff check . && black --check . --target-version py312 && mypy pyfragment test: - name: Tests + name: Tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true COOKIES_JSON: ${{ secrets.COOKIES_JSON }} + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13", "3.14"] + steps: - uses: actions/checkout@v6.0.2 - - uses: actions/setup-python@v6.2.0 + - name: Set up conda (Miniconda) + uses: conda-incubator/setup-miniconda@v3 with: - python-version: "3.12" + auto-update-conda: true + python-version: ${{ matrix.python-version }} + activate-environment: pyfragment-test + auto-activate-base: false - - uses: astral-sh/setup-uv@v7.6.0 + - name: Install package and dev dependencies + shell: bash -el {0} + run: pip install ".[dev]" - - run: uv pip install --system ".[dev]" - - - run: pytest + - name: Run tests + shell: bash -el {0} + run: pytest diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py index d21702e..cffc227 100644 --- a/pyfragment/utils/http.py +++ b/pyfragment/utils/http.py @@ -114,7 +114,7 @@ async def fragment_request( async def execute_transaction_request( session: httpx.AsyncClient, - headers: dict, + headers: dict[str, str], tx_data: dict[str, Any], fragment_hash: str, ) -> dict[str, Any]: diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index db4620c..6587786 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from pyfragment.client import FragmentClient -async def process_transaction(client: "FragmentClient", transaction_data: dict) -> str: +async def process_transaction(client: "FragmentClient", transaction_data: dict[str, Any]) -> str: """Sign and broadcast a Fragment transaction to the TON network. Validates the payload structure, checks the wallet balance, decodes the diff --git a/pyproject.toml b/pyproject.toml index 753fb5e..4655514 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] dependencies = [ "httpx==0.28.1", - "tonutils[pytoniq]==2.0.4", + "tonutils[pytoniq]==2.0.5", ] [project.optional-dependencies] From 733d138fccb16008e1dccea5189cfaac3b863899 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 00:12:22 +0300 Subject: [PATCH 04/12] feat: Enhance cookie handling and browser support - Added new exceptions in `exceptions.py` for unsupported browsers and cookie read failures. - Introduced `get_cookies_from_browser` function in `cookies.py` to extract session cookies from various browsers. - Modified tests to cover new cookie extraction functionality, including success and failure cases. - Refactored existing tests to use `patch.object` for mocking client calls. - Updated dependencies in `pyproject.toml` to include `rookiepy` for cookie extraction. --- .github/workflows/ci.yml | 2 +- pyfragment/__init__.py | 2 + pyfragment/methods/anonymous_number.py | 78 ++++++++---------- pyfragment/methods/giveaway_premium.py | 98 +++++++---------------- pyfragment/methods/giveaway_stars.py | 90 ++++++--------------- pyfragment/methods/purchase_premium.py | 102 ++++++------------------ pyfragment/methods/purchase_stars.py | 85 +++++--------------- pyfragment/methods/recharge_ads.py | 61 ++++---------- pyfragment/methods/search_gifts.py | 10 +-- pyfragment/methods/search_numbers.py | 10 +-- pyfragment/methods/search_usernames.py | 10 +-- pyfragment/methods/topup_ton.py | 87 ++++++-------------- pyfragment/types/constants.py | 24 +++++- pyfragment/types/exceptions.py | 8 ++ pyfragment/utils/__init__.py | 2 + pyfragment/utils/cookies.py | 45 +++++++++++ pyfragment/utils/decoder.py | 2 +- pyfragment/utils/wallet.py | 2 +- pyproject.toml | 5 +- tests/005_test_stars.py | 48 +++++------ tests/006_test_premium.py | 41 +++++----- tests/007_test_topup.py | 26 +++--- tests/010_test_number.py | 105 +++++++++---------------- tests/011_test_recharge_ads.py | 12 +-- tests/012_test_usernames.py | 56 ++++--------- tests/013_test_numbers.py | 56 ++++--------- tests/014_test_gifts.py | 78 +++++------------- tests/015_test_cookies.py | 99 +++++++++++++++++++++++ 28 files changed, 507 insertions(+), 737 deletions(-) create mode 100644 pyfragment/utils/cookies.py create mode 100644 tests/015_test_cookies.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dd09d8..04ee292 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: with: python-version: "3.12" - - uses: astral-sh/setup-uv@v7.6.0 + - uses: astral-sh/setup-uv@v8.0.0 - run: uv pip install --system ".[dev]" diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index 495897d..53073f7 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -34,12 +34,14 @@ from pyfragment.types import ( WalletError, WalletInfo, ) +from pyfragment.utils.cookies import get_cookies_from_browser __version__: str = version("pyfragment") __all__ = [ "__version__", "FragmentClient", + "get_cookies_from_browser", "AdsRechargeResult", "AdsTopupResult", "GiftsResult", diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/methods/anonymous_number.py index b8facfe..41c317b 100644 --- a/pyfragment/methods/anonymous_number.py +++ b/pyfragment/methods/anonymous_number.py @@ -1,18 +1,14 @@ 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 +from pyfragment.utils import 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 @@ -35,14 +31,11 @@ async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResu """ 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"}, - ) + result = await client.call( + "updateLoginCodes", + {"number": clean, "lt": "0", "from_app": "1"}, + page_url=NUMBERS_PAGE, + ) if result.get("html"): code, active_sessions = parse_login_code(result["html"]) @@ -71,14 +64,11 @@ async def toggle_login_codes(client: "FragmentClient", number: str, can_receive: """ 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"}, - ) + result = await client.call( + "toggleLoginCodes", + {"number": clean, "can_receive": 1 if can_receive else 0}, + page_url=NUMBERS_PAGE, + ) if result.get("error"): raise FragmentAPIError(html.unescape(result["error"])) @@ -110,39 +100,33 @@ async def terminate_sessions(client: "FragmentClient", number: str) -> Terminate """ 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"}, + confirmation = await client.call( + "terminatePhoneSessions", + {"number": clean}, + page_url=NUMBERS_PAGE, + ) + + if confirmation.get("error"): + raise AnonymousNumberError( + AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(confirmation["error"])) ) - 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)) - terminate_hash = confirmation.get("terminate_hash") - if not terminate_hash: - raise AnonymousNumberError(AnonymousNumberError.NOT_OWNED.format(number=number)) + result = await client.call( + "terminatePhoneSessions", + {"number": clean, "terminate_hash": terminate_hash}, + page_url=NUMBERS_PAGE, + ) - # 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"])) ) - 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: diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py index 6dfe462..d152b62 100644 --- a/pyfragment/methods/giveaway_premium.py +++ b/pyfragment/methods/giveaway_premium.py @@ -1,79 +1,21 @@ import json from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, UserNotFoundError, + VerificationError, ) 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, -) +from pyfragment.utils import get_account_info, 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", @@ -105,21 +47,37 @@ async def giveaway_premium( raise ConfigurationError(ConfigurationError.INVALID_MONTHS) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_GIVEAWAY_PAGE, client.timeout) + result = await client.call( + "searchPremiumGiveawayRecipient", + {"query": channel, "quantity": winners, "months": months}, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + + result = await client.call( + "initGiveawayPremiumRequest", + {"recipient": recipient, "quantity": str(winners), "months": str(months)}, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway")) + 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 = { + transaction = await client.call( + "getGiveawayPremiumLink", + { "account": json.dumps(account), "device": DEVICE, "transaction": 1, "id": req_id, - "method": "getGiveawayPremiumLink", - } - transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + }, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return PremiumGiveawayResult( diff --git a/pyfragment/methods/giveaway_stars.py b/pyfragment/methods/giveaway_stars.py index 5eb4ef8..ab70340 100644 --- a/pyfragment/methods/giveaway_stars.py +++ b/pyfragment/methods/giveaway_stars.py @@ -1,75 +1,21 @@ import json from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, UserNotFoundError, + VerificationError, ) 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, -) +from pyfragment.utils import get_account_info, 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", @@ -101,21 +47,33 @@ async def giveaway_stars( raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_GIVEAWAY_PAGE, client.timeout) + result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + + result = await client.call( + "initGiveawayStarsRequest", + {"recipient": recipient, "quantity": str(winners), "stars": str(amount)}, + page_url=STARS_GIVEAWAY_PAGE, + ) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway")) + 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 = { + transaction = await client.call( + "getGiveawayStarsLink", + { "account": json.dumps(account), "device": DEVICE, "transaction": 1, "id": req_id, - "method": "getGiveawayStarsLink", - } - transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + }, + page_url=STARS_GIVEAWAY_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return StarsGiveawayResult( diff --git a/pyfragment/methods/purchase_premium.py b/pyfragment/methods/purchase_premium.py index 6e189e5..5b81ae4 100644 --- a/pyfragment/methods/purchase_premium.py +++ b/pyfragment/methods/purchase_premium.py @@ -2,8 +2,6 @@ import json import time from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( ConfigurationError, FragmentAPIError, @@ -11,77 +9,14 @@ from pyfragment.types import ( PremiumResult, UnexpectedError, UserNotFoundError, + VerificationError, ) from pyfragment.types.constants import DEVICE, PREMIUM_PAGE -from pyfragment.utils import ( - execute_transaction_request, - fragment_request, - get_account_info, - get_fragment_hash, - make_headers, - process_transaction, -) +from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(PREMIUM_PAGE) - - -async def _search_recipient( - session: httpx.AsyncClient, - fragment_hash: str, - username: str, - months: int, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "query": username, - "months": months, - "method": "searchPremiumGiftRecipient", - }, - ) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) - return recipient - - -async def _init_request( - session: httpx.AsyncClient, - fragment_hash: str, - recipient: str, - months: int, -) -> str: - await fragment_request( - session, - fragment_hash, - HEADERS, - { - "mode": "new", - "lv": "false", - "dh": str(int(time.time())), - "method": "updatePremiumState", - }, - ) - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "recipient": recipient, - "months": months, - "method": "initGiftPremiumRequest", - }, - ) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) - return req_id - async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult: """Gift Telegram Premium to a user. @@ -105,22 +40,35 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int, raise ConfigurationError(ConfigurationError.INVALID_MONTHS) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE, client.timeout) + result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) + + await client.call( + "updatePremiumState", + {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, + page_url=PREMIUM_PAGE, + ) + result = await client.call("initGiftPremiumRequest", {"recipient": recipient, "months": months}, page_url=PREMIUM_PAGE) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) + 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, username, months) - req_id = await _init_request(session, fragment_hash, recipient, months) - - tx_data = { + transaction = await client.call( + "getGiftPremiumLink", + { "account": json.dumps(account), "device": DEVICE, "transaction": 1, "id": req_id, "show_sender": int(show_sender), - "method": "getGiftPremiumLink", - } - transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + }, + page_url=PREMIUM_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return PremiumResult(transaction_id=tx_hash, username=username, amount=months) diff --git a/pyfragment/methods/purchase_stars.py b/pyfragment/methods/purchase_stars.py index 21db354..be71352 100644 --- a/pyfragment/methods/purchase_stars.py +++ b/pyfragment/methods/purchase_stars.py @@ -1,8 +1,6 @@ import json from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( ConfigurationError, FragmentAPIError, @@ -10,65 +8,14 @@ from pyfragment.types import ( StarsResult, UnexpectedError, UserNotFoundError, + VerificationError, ) from pyfragment.types.constants import DEVICE, STARS_PAGE -from pyfragment.utils import ( - execute_transaction_request, - fragment_request, - get_account_info, - get_fragment_hash, - make_headers, - process_transaction, -) +from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(STARS_PAGE) - - -async def _search_recipient( - session: httpx.AsyncClient, - fragment_hash: str, - username: str, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "query": username, - "quantity": "", - "method": "searchStarsRecipient", - }, - ) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) - return recipient - - -async def _init_request( - session: httpx.AsyncClient, - fragment_hash: str, - recipient: str, - amount: int, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "recipient": recipient, - "quantity": amount, - "method": "initBuyStarsRequest", - }, - ) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) - return req_id - async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult: """Send Telegram Stars to a user. @@ -92,22 +39,30 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE, client.timeout) + result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) + + result = await client.call("initBuyStarsRequest", {"recipient": recipient, "quantity": amount}, page_url=STARS_PAGE) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) + 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, username) - req_id = await _init_request(session, fragment_hash, recipient, amount) - - tx_data = { + transaction = await client.call( + "getBuyStarsLink", + { "account": json.dumps(account), "device": DEVICE, "transaction": 1, "id": req_id, "show_sender": int(show_sender), - "method": "getBuyStarsLink", - } - transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + }, + page_url=STARS_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return StarsResult(transaction_id=tx_hash, username=username, amount=amount) diff --git a/pyfragment/methods/recharge_ads.py b/pyfragment/methods/recharge_ads.py index 7d27669..37bf3bf 100644 --- a/pyfragment/methods/recharge_ads.py +++ b/pyfragment/methods/recharge_ads.py @@ -1,52 +1,20 @@ import json from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, + VerificationError, ) 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, -) +from pyfragment.utils import get_account_info, 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. @@ -69,21 +37,26 @@ async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> A raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout) + await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE) + + result = await client.call("initAdsRechargeRequest", {"account": account, "amount": amount}, page_url=ADS_TOPUP_PAGE) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge")) + 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 = { + transaction = await client.call( + "getAdsRechargeLink", + { "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) + }, + page_url=ADS_TOPUP_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return AdsRechargeResult(transaction_id=tx_hash, amount=amount) diff --git a/pyfragment/methods/search_gifts.py b/pyfragment/methods/search_gifts.py index d1276b4..f871eb1 100644 --- a/pyfragment/methods/search_gifts.py +++ b/pyfragment/methods/search_gifts.py @@ -1,17 +1,13 @@ 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 +from pyfragment.utils import 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", @@ -64,9 +60,7 @@ async def search_gifts( 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) + result = await client.call("searchAuctions", data, page_url=GIFTS_PAGE) if result.get("error"): raise FragmentAPIError(result["error"]) diff --git a/pyfragment/methods/search_numbers.py b/pyfragment/methods/search_numbers.py index e6f459a..d8af150 100644 --- a/pyfragment/methods/search_numbers.py +++ b/pyfragment/methods/search_numbers.py @@ -1,17 +1,13 @@ 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 +from pyfragment.utils import 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", @@ -49,9 +45,7 @@ async def search_numbers( 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) + result = await client.call("searchAuctions", data, page_url=NUMBERS_PAGE) if result.get("error"): raise FragmentAPIError(result["error"]) diff --git a/pyfragment/methods/search_usernames.py b/pyfragment/methods/search_usernames.py index 23af084..83d8872 100644 --- a/pyfragment/methods/search_usernames.py +++ b/pyfragment/methods/search_usernames.py @@ -1,17 +1,13 @@ 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 +from pyfragment.utils import 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", @@ -49,9 +45,7 @@ async def search_usernames( 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) + result = await client.call("searchAuctions", data, page_url=FRAGMENT_BASE_URL) if result.get("error"): raise FragmentAPIError(result["error"]) diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/methods/topup_ton.py index 117ea87..e503872 100644 --- a/pyfragment/methods/topup_ton.py +++ b/pyfragment/methods/topup_ton.py @@ -1,8 +1,6 @@ import json from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( AdsTopupResult, ConfigurationError, @@ -10,65 +8,14 @@ from pyfragment.types import ( FragmentError, UnexpectedError, UserNotFoundError, + VerificationError, ) from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE -from pyfragment.utils import ( - execute_transaction_request, - fragment_request, - get_account_info, - get_fragment_hash, - make_headers, - process_transaction, -) +from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE) - - -async def _search_recipient( - session: httpx.AsyncClient, - fragment_hash: str, - username: str, -) -> str: - await fragment_request(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"}) - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "query": username, - "method": "searchAdsTopupRecipient", - }, - ) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) - return recipient - - -async def _init_request( - session: httpx.AsyncClient, - fragment_hash: str, - recipient: str, - amount: int, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "recipient": recipient, - "amount": amount, - "method": "initAdsTopupRequest", - }, - ) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) - return req_id - async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: """Topup ton to recipient's Telegram balance. @@ -92,22 +39,32 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout) + await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE) + + result = await client.call("searchAdsTopupRecipient", {"query": username}, page_url=ADS_TOPUP_PAGE) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) + + result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) + 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, username) - req_id = await _init_request(session, fragment_hash, recipient, amount) - - tx_data = { + transaction = await client.call( + "getAdsTopupLink", + { "account": json.dumps(account), "device": DEVICE, "transaction": 1, "id": req_id, "show_sender": int(show_sender), - "method": "getAdsTopupLink", - } - transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + }, + page_url=ADS_TOPUP_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount) diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index fee850a..115a73c 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -19,8 +19,9 @@ DEFAULT_TIMEOUT: float = 30.0 # Required Fragment session cookie keys REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") -# Fragment page URLs -FRAGMENT_BASE_URL: str = "https://fragment.com" +# Fragment domain and page URLs +FRAGMENT_DOMAIN: str = "fragment.com" # for rookiepy +FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}" 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" @@ -29,6 +30,25 @@ 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" +# Browsers supported by get_cookies_from_browser() +SUPPORTED_BROWSERS: frozenset[str] = frozenset( + { + "arc", + "brave", + "chrome", + "chromium", + "chromium_based", + "edge", + "firefox", + "firefox_based", + "librewolf", + "opera", + "opera_gx", + "safari", + "vivaldi", + } +) + # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. DEVICE: str = json.dumps( { diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 5207a94..3027d74 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -35,6 +35,14 @@ class CookieError(ClientError): "Fragment cookies are missing or empty for key(s): {keys}. " "Open fragment.com in your browser, log in, and copy fresh cookies." ) + UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}." + BROWSER_READ_FAILED = ( + "Failed to read {browser} cookies: {exc}. " "Make sure {browser} is installed and you are logged in to {url}." + ) + MISSING_BROWSER_KEYS = ( + "Fragment cookies not found in {browser}: {keys}. " + "Make sure you are logged in to {url} and have connected your TON wallet in {browser}." + ) class FragmentAPIError(FragmentError): diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index a52cf8b..843a2fa 100644 --- a/pyfragment/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,3 +1,4 @@ +from pyfragment.utils.cookies import get_cookies_from_browser 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 ( @@ -11,6 +12,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", + "get_cookies_from_browser", "parse_auction_rows", "parse_gift_items", "parse_login_code", diff --git a/pyfragment/utils/cookies.py b/pyfragment/utils/cookies.py new file mode 100644 index 0000000..77f2205 --- /dev/null +++ b/pyfragment/utils/cookies.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import rookiepy + +from pyfragment.types import CookieError +from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS + + +def get_cookies_from_browser(browser: str = "chrome") -> dict[str, str]: + """Extract Fragment session cookies directly from an installed browser. + + Reads the browser's on-disk cookie store (no extension required) and + returns the four cookies required by :class:`~pyfragment.FragmentClient`. + + Args: + browser: Browser name to read cookies from — case-insensitive. Supported values: + ``"chrome"`` (default), ``"firefox"``, ``"edge"``, ``"brave"``, ``"arc"``, + ``"opera"``, ``"opera_gx"``, ``"chromium"``, ``"chromium_based"``, + ``"firefox_based"``, ``"vivaldi"``, ``"librewolf"``, ``"safari"``. + + Returns: + A dict with the four required Fragment cookie keys: + ``stel_ssid``, ``stel_dt``, ``stel_token``, ``stel_ton_token``. + + Raises: + CookieError: If the browser is not supported, cookies cannot be read, + or required keys are missing. + """ + key = browser.lower() + if key not in SUPPORTED_BROWSERS: + supported = ", ".join(sorted(SUPPORTED_BROWSERS)) + raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported)) + + try: + jar: list[dict] = getattr(rookiepy, key)([FRAGMENT_DOMAIN]) + except Exception as exc: + raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc + + cookie_map: dict[str, str] = {c["name"]: c["value"] for c in jar if c.get("name") and c.get("value")} + + missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookie_map.get(k, "")).strip()] + if missing: + raise CookieError(CookieError.MISSING_BROWSER_KEYS.format(browser=browser, keys=missing, url=FRAGMENT_BASE_URL)) + + return {k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS} diff --git a/pyfragment/utils/decoder.py b/pyfragment/utils/decoder.py index 451a7b6..5acae41 100644 --- a/pyfragment/utils/decoder.py +++ b/pyfragment/utils/decoder.py @@ -1,6 +1,6 @@ import base64 -from pytoniq_core import Cell +from ton_core import Cell from pyfragment.types import ParseError diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 6587786..7abd120 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -3,9 +3,9 @@ import base64 import ssl from typing import TYPE_CHECKING, Any +from ton_core import NetworkGlobalID from tonutils.clients import TonapiClient from tonutils.exceptions import ProviderResponseError -from tonutils.types import NetworkGlobalID from pyfragment.types import TransactionError, WalletError from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES diff --git a/pyproject.toml b/pyproject.toml index 4655514..18204a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,12 +27,13 @@ classifiers = [ ] dependencies = [ "httpx==0.28.1", - "tonutils[pytoniq]==2.0.5", + "rookiepy==0.5.6", + "tonutils==2.1.0", ] [project.optional-dependencies] dev = [ - "pytest==9.0.2", + "pytest==9.0.3", "pytest-asyncio==1.3.0", "pytest-mock", "mypy", diff --git a/tests/005_test_stars.py b/tests/005_test_stars.py index 0a87f94..9ec952e 100644 --- a/tests/005_test_stars.py +++ b/tests/005_test_stars.py @@ -6,7 +6,7 @@ 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 +from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Stars purchase validation tests @@ -35,13 +35,18 @@ async def test_purchase_stars_float_amount(client: FragmentClient) -> None: @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.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, + ] + ), ), - patch("pyfragment.methods.purchase_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.purchase_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.purchase_stars("@user", amount=500) @@ -54,11 +59,7 @@ async def test_purchase_stars_success(client: FragmentClient) -> None: @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 patch.object(client, "call", AsyncMock(return_value={"found": {}})): with pytest.raises(UserNotFoundError): await client.purchase_stars("@ghost", amount=500) @@ -108,13 +109,18 @@ async def test_giveaway_stars_float_amount(client: FragmentClient) -> None: @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.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, + ] + ), ), - patch("pyfragment.methods.giveaway_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.giveaway_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.giveaway_stars("@channel", winners=3, amount=1000) @@ -128,10 +134,6 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None: @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 patch.object(client, "call", AsyncMock(return_value={"found": {}})): with pytest.raises(UserNotFoundError): await client.giveaway_stars("@ghost", winners=1, amount=500) diff --git a/tests/006_test_premium.py b/tests/006_test_premium.py index 0a0b11e..54283d3 100644 --- a/tests/006_test_premium.py +++ b/tests/006_test_premium.py @@ -6,7 +6,7 @@ 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 +from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Premium purchase validation tests @@ -29,19 +29,19 @@ async def test_purchase_premium_months_zero(client: FragmentClient) -> None: @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", + patch.object( + client, + "call", AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, {}, # updatePremiumState {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, ] ), ), - patch("pyfragment.methods.purchase_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.purchase_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.purchase_premium("@user", months=3) @@ -54,11 +54,7 @@ async def test_purchase_premium_success(client: FragmentClient) -> None: @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 patch.object(client, "call", AsyncMock(return_value={"found": {}})): with pytest.raises(UserNotFoundError): await client.purchase_premium("@ghost", months=3) @@ -96,13 +92,18 @@ async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None: @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.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, + ] + ), ), - patch("pyfragment.methods.giveaway_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.giveaway_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.giveaway_premium("@channel", winners=10, months=3) @@ -116,10 +117,6 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None: @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 patch.object(client, "call", 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 index 2b491bc..67dbd7a 100644 --- a/tests/007_test_topup.py +++ b/tests/007_test_topup.py @@ -6,7 +6,7 @@ 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 +from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Topup TON validation tests @@ -35,19 +35,19 @@ async def test_topup_ton_float_amount(client: FragmentClient) -> None: @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", + patch.object( + client, + "call", AsyncMock( side_effect=[ {}, # updateAdsTopupState {"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, ] ), ), - patch("pyfragment.methods.topup_ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.topup_ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.topup_ton("@user", amount=10) @@ -60,12 +60,14 @@ async def test_topup_ton_success(client: FragmentClient) -> None: @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 patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {}, # updateAdsTopupState + {"found": {}}, + ] ), ): with pytest.raises(UserNotFoundError): diff --git a/tests/010_test_number.py b/tests/010_test_number.py index bdcdb02..78c50eb 100644 --- a/tests/010_test_number.py +++ b/tests/010_test_number.py @@ -5,20 +5,14 @@ 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 +from tests.shared import FAKE_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH -# get_login_code tests +# get_login_code mocked 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}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_WITH_CODE})): result = await client.get_login_code("+1234567890") assert isinstance(result, LoginCodeResult) @@ -29,13 +23,7 @@ async def test_get_login_code_returns_code(client: FragmentClient) -> None: @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}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_NO_CODE})): result = await client.get_login_code("1234567890") assert result.code is None @@ -44,31 +32,26 @@ async def test_get_login_code_no_pending_code(client: FragmentClient) -> None: @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={})), - ): + with patch.object(client, "call", AsyncMock(return_value={})): result = await client.get_login_code("+1234567890") assert result.code is None assert result.active_sessions == 0 -# terminate_sessions tests +# terminate_sessions mocked 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 - ] - ), + with patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"terminate_hash": FAKE_TERMINATE_HASH}, # step 1: confirmation + {"msg": "All sessions terminated"}, # step 2: confirmed + ] ), ): result = await client.terminate_sessions("+1234567890") @@ -80,84 +63,66 @@ async def test_terminate_sessions_success(client: FragmentClient) -> None: @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 patch.object(client, "call", 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 patch.object(client, "call", 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 patch.object( + client, + "call", + 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 +# toggle_login_codes mocked 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, - ): + mock_call = AsyncMock(return_value={"ok": True}) + with patch.object(client, "call", mock_call): await client.toggle_login_codes("+1234567890", can_receive=True) - call_data = mock_req.call_args[0][3] + call_data = mock_call.call_args[0][1] 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, - ): + mock_call = AsyncMock(return_value={"ok": True}) + with patch.object(client, "call", mock_call): await client.toggle_login_codes("+1234567890", can_receive=False) - call_data = mock_req.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["can_receive"] == 0 -# strip_plus tests +# strip_plus mocked 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, - ): + mock_call = AsyncMock(return_value={}) + with patch.object(client, "call", mock_call): await client.get_login_code("+1234567890") - call_data = mock_req.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["number"] == "1234567890" diff --git a/tests/011_test_recharge_ads.py b/tests/011_test_recharge_ads.py index e1bf593..2c9300c 100644 --- a/tests/011_test_recharge_ads.py +++ b/tests/011_test_recharge_ads.py @@ -6,7 +6,7 @@ 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 +from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # recharge_ads validation tests @@ -35,18 +35,18 @@ async def test_recharge_ads_float_amount(client: FragmentClient) -> None: @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", + patch.object( + client, + "call", AsyncMock( side_effect=[ {}, # updateAdsState {"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest + FAKE_TRANSACTION, # getAdsRechargeLink ] ), ), - patch("pyfragment.methods.recharge_ads.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.recharge_ads.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.recharge_ads.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10) diff --git a/tests/012_test_usernames.py b/tests/012_test_usernames.py index 97ef2fe..a7a0816 100644 --- a/tests/012_test_usernames.py +++ b/tests/012_test_usernames.py @@ -6,7 +6,6 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import UsernamesResult -from tests.shared import FAKE_HASH FAKE_HTML = """ @@ -22,18 +21,12 @@ FAKE_HTML = """ """ -# search_usernames mocked tests +# search_usernames result parsing tests @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}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})): result = await client.search_usernames("coolname") assert isinstance(result, UsernamesResult) @@ -46,13 +39,7 @@ async def test_search_usernames_basic(client: FragmentClient) -> 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}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True})): result = await client.search_usernames("zzz_no_results") assert isinstance(result, UsernamesResult) @@ -60,21 +47,18 @@ async def test_search_usernames_empty_html(client: FragmentClient) -> None: assert result.next_offset_id is None +# search_usernames parameter forwarding tests + + @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, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_usernames("durov", sort="price_desc", filter="auction") assert isinstance(result, UsernamesResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] 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" @@ -82,33 +66,23 @@ async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> @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, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}) + with patch.object(client, "call", mock_call): 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] + call_data = mock_call.call_args[0][1] 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, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_usernames() assert isinstance(result, UsernamesResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["query"] == "" assert call_data["type"] == "usernames" diff --git a/tests/013_test_numbers.py b/tests/013_test_numbers.py index daa1293..50b25d3 100644 --- a/tests/013_test_numbers.py +++ b/tests/013_test_numbers.py @@ -6,7 +6,6 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import NumbersResult -from tests.shared import FAKE_HASH FAKE_HTML = """ @@ -22,18 +21,12 @@ FAKE_HTML = """ """ -# search_numbers mocked tests +# search_numbers result parsing tests @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}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})): result = await client.search_numbers("888") assert isinstance(result, NumbersResult) @@ -46,13 +39,7 @@ async def test_search_numbers_basic(client: FragmentClient) -> 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}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True})): result = await client.search_numbers("zzz_no_results") assert isinstance(result, NumbersResult) @@ -60,21 +47,18 @@ async def test_search_numbers_empty_html(client: FragmentClient) -> None: assert result.next_offset_id is None +# search_numbers parameter forwarding tests + + @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, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_numbers("888", sort="price_asc", filter="sale") assert isinstance(result, NumbersResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] 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" @@ -82,33 +66,23 @@ async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> No @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, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"}) + with patch.object(client, "call", mock_call): 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] + call_data = mock_call.call_args[0][1] 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, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_numbers() assert isinstance(result, NumbersResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["query"] == "" assert call_data["type"] == "numbers" diff --git a/tests/014_test_gifts.py b/tests/014_test_gifts.py index fd6e72a..747dab5 100644 --- a/tests/014_test_gifts.py +++ b/tests/014_test_gifts.py @@ -6,7 +6,6 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import GiftsResult -from tests.shared import FAKE_HASH FAKE_GIFTS_HTML = """
@@ -48,18 +47,12 @@ FAKE_GIFTS_HTML = """ """ -# search_gifts mocked tests +# search_gifts result parsing tests @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}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})): result = await client.search_gifts() assert isinstance(result, GiftsResult) @@ -76,13 +69,7 @@ async def test_search_gifts_basic(client: FragmentClient) -> None: @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}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True})): result = await client.search_gifts(query="zzz_no_results") assert isinstance(result, GiftsResult) @@ -90,68 +77,50 @@ async def test_search_gifts_empty(client: FragmentClient) -> None: assert result.next_offset is None +# search_gifts parameter forwarding tests + + @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, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_gifts(collection="plushpepe", sort="price_desc", filter="sold") assert isinstance(result, GiftsResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] 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, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_gifts(offset=60) assert isinstance(result, GiftsResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] 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, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_gifts(collection="artisanbrick", view="Model") assert isinstance(result, GiftsResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] 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, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_gifts( collection="artisanbrick", sort="listed", @@ -164,7 +133,7 @@ async def test_search_gifts_with_attr(client: FragmentClient) -> None: ) assert isinstance(result, GiftsResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] 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"] @@ -176,16 +145,11 @@ async def test_search_gifts_with_attr(client: FragmentClient) -> None: @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, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_gifts() assert isinstance(result, GiftsResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert "view" not in call_data assert not any(k.startswith("attr[") for k in call_data) diff --git a/tests/015_test_cookies.py b/tests/015_test_cookies.py new file mode 100644 index 0000000..bb45a36 --- /dev/null +++ b/tests/015_test_cookies.py @@ -0,0 +1,99 @@ +"""Unit tests for get_cookies_from_browser() — browser cookie extraction helper.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from pyfragment import get_cookies_from_browser +from pyfragment.types import CookieError +from pyfragment.types.constants import REQUIRED_COOKIE_KEYS + +FAKE_JAR = [ + {"name": "stel_ssid", "value": "abc123", "domain": "fragment.com"}, + {"name": "stel_dt", "value": "-120", "domain": "fragment.com"}, + {"name": "stel_token", "value": "tok_xyz", "domain": "fragment.com"}, + {"name": "stel_ton_token", "value": "ton_xyz", "domain": "fragment.com"}, + {"name": "unrelated", "value": "noise", "domain": "fragment.com"}, +] + + +def _mock_rookiepy(jar: list[dict] | None = None) -> MagicMock: + mock = MagicMock() + mock.chrome.return_value = jar if jar is not None else FAKE_JAR + return mock + + +# unsupported browser tests + + +def test_unsupported_browser_raises() -> None: + with pytest.raises(CookieError, match="Unsupported browser"): + get_cookies_from_browser("internet_explorer") + + +# successful extraction tests + + +def test_returns_required_keys_only() -> None: + with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy()}): + result = get_cookies_from_browser("chrome") + + assert set(result.keys()) == set(REQUIRED_COOKIE_KEYS) + assert result["stel_ssid"] == "abc123" + assert result["stel_dt"] == "-120" + assert result["stel_token"] == "tok_xyz" + assert result["stel_ton_token"] == "ton_xyz" + assert "unrelated" not in result + + +def test_default_browser_is_chrome() -> None: + mock_rp = _mock_rookiepy() + with patch.dict("sys.modules", {"rookiepy": mock_rp}): + get_cookies_from_browser() + + mock_rp.chrome.assert_called_once_with(["fragment.com"]) + + +def test_browser_name_is_case_insensitive() -> None: + mock_rp = _mock_rookiepy() + with patch.dict("sys.modules", {"rookiepy": mock_rp}): + result = get_cookies_from_browser("Chrome") + + assert result["stel_ssid"] == "abc123" + + +# missing cookies tests + + +def test_missing_cookies_raises() -> None: + partial_jar = [ + {"name": "stel_ssid", "value": "abc123"}, + {"name": "stel_dt", "value": "-120"}, + # stel_token and stel_ton_token missing + ] + with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy(partial_jar)}): + with pytest.raises(CookieError, match="Fragment cookies not found in chrome"): + get_cookies_from_browser("chrome") + + +def test_empty_cookie_value_treated_as_missing() -> None: + jar_with_empty = [ + {"name": "stel_ssid", "value": "abc123"}, + {"name": "stel_dt", "value": ""}, # empty — should be treated as missing + {"name": "stel_token", "value": "tok_xyz"}, + {"name": "stel_ton_token", "value": "ton_xyz"}, + ] + with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy(jar_with_empty)}): + with pytest.raises(CookieError, match="Fragment cookies not found in chrome"): + get_cookies_from_browser("chrome") + + +# read failure tests + + +def test_browser_read_error_raises() -> None: + mock_rp = MagicMock() + mock_rp.chrome.side_effect = PermissionError("locked") + with patch.dict("sys.modules", {"rookiepy": mock_rp}): + with pytest.raises(CookieError, match="Failed to read chrome cookies"): + get_cookies_from_browser("chrome") From ebd45d29917acc681e896ecc707ccf538c89296f Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 00:22:48 +0300 Subject: [PATCH 05/12] Add unit tests for FragmentClient methods - Implement tests for Stars methods: purchase_stars and giveaway_stars, including validation and success scenarios. - Add tests for Premium methods: purchase_premium and giveaway_premium, covering validation and success cases. - Create tests for topup_ton functionality, validating input and mocking successful operations. - Introduce wallet tests to verify get_wallet() functionality, ensuring correct wallet info retrieval. - Add tests for FragmentClient.call() method, validating API response handling and data merging. - Implement tests for anonymous number methods, including login codes and session management. - Create recharge_ads tests for self-service Telegram Ads recharge, validating input and success scenarios. - Add username search tests for the Fragment marketplace, ensuring correct result parsing and parameter forwarding. - Implement number search tests for the Fragment marketplace, validating result parsing and parameter handling. - Create gift search tests for the Fragment gifts marketplace, ensuring correct result parsing and parameter forwarding. - Add cookie extraction tests for get_cookies_from_browser(), validating successful extraction and error handling. --- examples/auctions/search_gifts.py | 6 ++++++ examples/auctions/search_numbers.py | 6 ++++++ examples/auctions/search_usernames.py | 6 ++++++ examples/client/raw_api_call.py | 6 ++++++ examples/client/wallet_info.py | 6 ++++++ examples/numbers/manage_number.py | 6 ++++++ examples/purchase/recharge_ads_balance.py | 6 ++++++ examples/purchase/run_premium_giveaway.py | 6 ++++++ examples/purchase/run_stars_giveaway.py | 6 ++++++ examples/purchase/send_premium.py | 6 ++++++ examples/purchase/send_stars.py | 6 ++++++ examples/purchase/topup_ton_balance.py | 12 +++++++++--- pyfragment/__init__.py | 2 -- pyfragment/client.py | 6 +++--- pyfragment/methods/topup_ton.py | 4 ++-- ...004_test_balance.py => 003_test_balance.py} | 0 tests/003_test_hash.py | 18 ------------------ tests/{005_test_stars.py => 004_test_stars.py} | 0 ...006_test_premium.py => 005_test_premium.py} | 0 tests/{007_test_topup.py => 006_test_topup.py} | 0 .../{008_test_wallet.py => 007_test_wallet.py} | 0 tests/{009_test_call.py => 008_test_call.py} | 0 .../{010_test_number.py => 009_test_number.py} | 0 ...echarge_ads.py => 010_test_recharge_ads.py} | 0 ...test_usernames.py => 011_test_usernames.py} | 0 ...013_test_numbers.py => 012_test_numbers.py} | 0 tests/{014_test_gifts.py => 013_test_gifts.py} | 0 ...015_test_cookies.py => 014_test_cookies.py} | 2 +- 28 files changed, 81 insertions(+), 29 deletions(-) rename tests/{004_test_balance.py => 003_test_balance.py} (100%) delete mode 100644 tests/003_test_hash.py rename tests/{005_test_stars.py => 004_test_stars.py} (100%) rename tests/{006_test_premium.py => 005_test_premium.py} (100%) rename tests/{007_test_topup.py => 006_test_topup.py} (100%) rename tests/{008_test_wallet.py => 007_test_wallet.py} (100%) rename tests/{009_test_call.py => 008_test_call.py} (100%) rename tests/{010_test_number.py => 009_test_number.py} (100%) rename tests/{011_test_recharge_ads.py => 010_test_recharge_ads.py} (100%) rename tests/{012_test_usernames.py => 011_test_usernames.py} (100%) rename tests/{013_test_numbers.py => 012_test_numbers.py} (100%) rename tests/{014_test_gifts.py => 013_test_gifts.py} (100%) rename tests/{015_test_cookies.py => 014_test_cookies.py} (98%) diff --git a/examples/auctions/search_gifts.py b/examples/auctions/search_gifts.py index 70ad338..4eb31ac 100644 --- a/examples/auctions/search_gifts.py +++ b/examples/auctions/search_gifts.py @@ -11,9 +11,15 @@ import asyncio import json from pyfragment import FragmentClient, GiftsResult +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/examples/auctions/search_numbers.py b/examples/auctions/search_numbers.py index 9a98a27..f8d55ba 100644 --- a/examples/auctions/search_numbers.py +++ b/examples/auctions/search_numbers.py @@ -10,9 +10,15 @@ import asyncio import json from pyfragment import FragmentClient, NumbersResult +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/examples/auctions/search_usernames.py b/examples/auctions/search_usernames.py index 68e5ea1..f478b03 100644 --- a/examples/auctions/search_usernames.py +++ b/examples/auctions/search_usernames.py @@ -10,9 +10,15 @@ import asyncio import json from pyfragment import FragmentClient, UsernamesResult +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/examples/client/raw_api_call.py b/examples/client/raw_api_call.py index 6bd9594..070ee7a 100644 --- a/examples/client/raw_api_call.py +++ b/examples/client/raw_api_call.py @@ -12,9 +12,15 @@ Defaults to the Fragment base URL. import asyncio from pyfragment import FragmentClient +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/examples/client/wallet_info.py b/examples/client/wallet_info.py index 84c8db4..4b4f14f 100644 --- a/examples/client/wallet_info.py +++ b/examples/client/wallet_info.py @@ -8,9 +8,15 @@ wallet_version defaults to "V5R1" — change to "V4R2" for older wallets. import asyncio from pyfragment import FragmentClient +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/examples/numbers/manage_number.py b/examples/numbers/manage_number.py index 3355b22..8251c2e 100644 --- a/examples/numbers/manage_number.py +++ b/examples/numbers/manage_number.py @@ -9,9 +9,15 @@ Use terminate_sessions() to forcefully end all active Telegram sessions. import asyncio from pyfragment import AnonymousNumberError, FragmentClient +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py index 6ec6121..56dde7a 100644 --- a/examples/purchase/recharge_ads_balance.py +++ b/examples/purchase/recharge_ads_balance.py @@ -13,9 +13,15 @@ from pyfragment import ( FragmentClient, WalletError, ) +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py index e2a5818..c152af9 100644 --- a/examples/purchase/run_premium_giveaway.py +++ b/examples/purchase/run_premium_giveaway.py @@ -8,9 +8,15 @@ months (Premium duration per winner) must be 3, 6, or 12. import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py index 400a707..a9188ba 100644 --- a/examples/purchase/run_stars_giveaway.py +++ b/examples/purchase/run_stars_giveaway.py @@ -8,9 +8,15 @@ amount (stars per winner) must be an integer between 500 and 1 000 000. import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py index fc90f3b..cd12734 100644 --- a/examples/purchase/send_premium.py +++ b/examples/purchase/send_premium.py @@ -8,9 +8,15 @@ Set show_sender=False to send anonymously. import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py index 594f883..73d77eb 100644 --- a/examples/purchase/send_stars.py +++ b/examples/purchase/send_stars.py @@ -8,9 +8,15 @@ Set show_sender=False to send anonymously. import asyncio from pyfragment import ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/examples/purchase/topup_ton_balance.py b/examples/purchase/topup_ton_balance.py index 10681ad..49900b7 100644 --- a/examples/purchase/topup_ton_balance.py +++ b/examples/purchase/topup_ton_balance.py @@ -1,10 +1,10 @@ """ -Example: Topup ton to recipient's Telegram balance. +Example: top up TON to a recipient's Telegram balance. -For adding TON to Telegram Ads account, use recharge_ads() instead. +For adding TON to a 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. +Your wallet must hold at least the top-up amount + ~0.056 TON for gas. """ import asyncio @@ -15,9 +15,15 @@ from pyfragment import ( UserNotFoundError, WalletError, ) +from pyfragment.utils import get_cookies_from_browser # noqa: F401 SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" + +# Option A: extract cookies directly from your browser (no manual copy-paste needed) +# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + +# Option B: provide cookies manually COOKIES = { "stel_ssid": "YOUR_STEL_SSID", "stel_dt": "YOUR_STEL_DT", diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index 53073f7..495897d 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -34,14 +34,12 @@ from pyfragment.types import ( WalletError, WalletInfo, ) -from pyfragment.utils.cookies import get_cookies_from_browser __version__: str = version("pyfragment") __all__ = [ "__version__", "FragmentClient", - "get_cookies_from_browser", "AdsRechargeResult", "AdsTopupResult", "GiftsResult", diff --git a/pyfragment/client.py b/pyfragment/client.py index 08bd9ab..d5f7e68 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -123,7 +123,7 @@ class FragmentClient: return f"FragmentClient(wallet_version='{self.wallet_version}', cookies={len(self.cookies)} keys)" async def purchase_premium(self, username: str, months: int, show_sender: bool = True) -> PremiumResult: - """Purchase Telegram Premium for a user. + """Gift Telegram Premium to a user. Args: username: Recipient's Telegram username (with or without ``@``). @@ -136,7 +136,7 @@ class FragmentClient: return await purchase_premium(self, username, months, show_sender) async def purchase_stars(self, username: str, amount: int, show_sender: bool = True) -> StarsResult: - """Purchase Telegram Stars for a user. + """Send Telegram Stars to a user. Args: username: Recipient's Telegram username (with or without ``@``). @@ -149,7 +149,7 @@ class FragmentClient: return await purchase_stars(self, username, amount, show_sender) async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - """Topup ton to recipient's Telegram balance. + """Top up TON to a recipient's Telegram balance. Args: username: Recipient's Telegram username (with or without ``@``). diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/methods/topup_ton.py index e503872..f19e342 100644 --- a/pyfragment/methods/topup_ton.py +++ b/pyfragment/methods/topup_ton.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - """Topup ton to recipient's Telegram balance. + """Top up TON to a recipient's Telegram balance. Args: client: Authenticated :class:`FragmentClient` instance. @@ -31,7 +31,7 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s Raises: ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000. - UserNotFoundError: If the recipient is not found on Fragment. + UserNotFoundError: If the recipient is not found on Telegram. FragmentAPIError: If the Fragment API returns an error. UnexpectedError: For any other unexpected failure. """ diff --git a/tests/004_test_balance.py b/tests/003_test_balance.py similarity index 100% rename from tests/004_test_balance.py rename to tests/003_test_balance.py diff --git a/tests/003_test_hash.py b/tests/003_test_hash.py deleted file mode 100644 index 2b72758..0000000 --- a/tests/003_test_hash.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Tests for get_fragment_hash() — API hash extraction from Fragment page source.""" - -import re - -import pytest - -from pyfragment.types.constants import BASE_HEADERS, STARS_PAGE -from pyfragment.utils import get_fragment_hash - -# Hash integration tests - - -@pytest.mark.asyncio -async def test_hash_is_valid_hex(cookies: dict) -> None: - result = await get_fragment_hash(cookies, BASE_HEADERS, STARS_PAGE) - assert isinstance(result, str) - assert len(result) >= 10, f"hash too short: {result!r}" - assert re.fullmatch(r"[a-f0-9]+", result), f"not a hex string: {result!r}" diff --git a/tests/005_test_stars.py b/tests/004_test_stars.py similarity index 100% rename from tests/005_test_stars.py rename to tests/004_test_stars.py diff --git a/tests/006_test_premium.py b/tests/005_test_premium.py similarity index 100% rename from tests/006_test_premium.py rename to tests/005_test_premium.py diff --git a/tests/007_test_topup.py b/tests/006_test_topup.py similarity index 100% rename from tests/007_test_topup.py rename to tests/006_test_topup.py diff --git a/tests/008_test_wallet.py b/tests/007_test_wallet.py similarity index 100% rename from tests/008_test_wallet.py rename to tests/007_test_wallet.py diff --git a/tests/009_test_call.py b/tests/008_test_call.py similarity index 100% rename from tests/009_test_call.py rename to tests/008_test_call.py diff --git a/tests/010_test_number.py b/tests/009_test_number.py similarity index 100% rename from tests/010_test_number.py rename to tests/009_test_number.py diff --git a/tests/011_test_recharge_ads.py b/tests/010_test_recharge_ads.py similarity index 100% rename from tests/011_test_recharge_ads.py rename to tests/010_test_recharge_ads.py diff --git a/tests/012_test_usernames.py b/tests/011_test_usernames.py similarity index 100% rename from tests/012_test_usernames.py rename to tests/011_test_usernames.py diff --git a/tests/013_test_numbers.py b/tests/012_test_numbers.py similarity index 100% rename from tests/013_test_numbers.py rename to tests/012_test_numbers.py diff --git a/tests/014_test_gifts.py b/tests/013_test_gifts.py similarity index 100% rename from tests/014_test_gifts.py rename to tests/013_test_gifts.py diff --git a/tests/015_test_cookies.py b/tests/014_test_cookies.py similarity index 98% rename from tests/015_test_cookies.py rename to tests/014_test_cookies.py index bb45a36..e0b7d94 100644 --- a/tests/015_test_cookies.py +++ b/tests/014_test_cookies.py @@ -4,9 +4,9 @@ from unittest.mock import MagicMock, patch import pytest -from pyfragment import get_cookies_from_browser from pyfragment.types import CookieError from pyfragment.types.constants import REQUIRED_COOKIE_KEYS +from pyfragment.utils import get_cookies_from_browser FAKE_JAR = [ {"name": "stel_ssid", "value": "abc123", "domain": "fragment.com"}, From 269551da2f0a9eb658239ae636a5fbfee34ab2b9 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 01:13:45 +0300 Subject: [PATCH 06/12] feat: Update CI workflows, add changelog extraction, and enhance README with cookie retrieval methods --- .github/workflows/ci.yml | 4 +--- .github/workflows/publish.yml | 10 +++++++++- .gitignore | 2 +- CHANGELOG.md | 18 ++++++++++++++++++ README.md | 11 ++++++++++- pyproject.toml | 3 ++- 6 files changed, 41 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04ee292..e7615c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,7 @@ jobs: with: python-version: "3.12" - - uses: astral-sh/setup-uv@v8.0.0 - - - run: uv pip install --system ".[dev]" + - run: pip install ".[dev]" - run: ruff check . && black --check . --target-version py312 && mypy pyfragment diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5497f05..bc0ece5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -100,10 +100,18 @@ jobs: name: dist path: dist + - name: Extract latest changelog entry + id: changelog + run: | + body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md) + echo "body<> $GITHUB_OUTPUT + echo "$body" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + - uses: softprops/action-gh-release@v2.6.1 with: tag_name: v${{ needs.version-check.outputs.version }} name: v${{ needs.version-check.outputs.version }} files: dist/* - generate_release_notes: true + body: ${{ steps.changelog.outputs.body }} make_latest: true diff --git a/.gitignore b/.gitignore index cda7104..7e968df 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,7 @@ Thumbs.db .ruff_cache/ .coverage htmlcov/ -demo.run.py +systests/ # Build & distribution dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e7598..4f85d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI --- +## [2026.2.0] — 2026-04-14 + +### Added + +- `get_cookies_from_browser(browser)` — extract Fragment session cookies directly from an installed browser (Chrome, Firefox, Edge, Brave, Arc, Opera, Safari, and more); no browser extension or manual copy-paste required + ```python + from pyfragment.utils import get_cookies_from_browser + cookies = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + client = FragmentClient(seed="...", api_key="...", cookies=cookies) + ``` + +### Changed + +- `tonutils` upgraded to **2.1.0** + +--- + ## [2026.1.0] — 2026-03-25 ### Added @@ -83,6 +100,7 @@ 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.2.0]: https://github.com/bohd4nx/pyfragment/releases/tag/v2026.2.0 [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 7ab264c..3d31da0 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,16 @@ Requires Python 3.12+. ## Credentials -**Fragment cookies** — log in to [fragment.com](https://fragment.com), install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm), and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or as a JSON string. Refresh when you get authentication errors. +**Fragment cookies** — log in to [fragment.com](https://fragment.com) and connect your TON wallet. You can get cookies in two ways: + +- **Automatically** (recommended) — use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed: + ```python + from pyfragment.utils import get_cookies_from_browser + cookies = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + ``` +- **Manually** — install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or JSON string. + +Refresh when you get authentication errors. **Tonapi key** — generate at [tonconsole.com](https://tonconsole.com). diff --git a/pyproject.toml b/pyproject.toml index 18204a9..f0218c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" -version = "2026.1.0" +version = "2026.2.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" } @@ -71,3 +71,4 @@ ignore = ["E501"] [tool.ruff.lint.per-file-ignores] "tests/*" = ["E402"] +"systests/*" = ["E402"] From 391d15221b125cf3e0a98db33b8dab5a8a50a242 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 01:25:25 +0300 Subject: [PATCH 07/12] feat: enhance cookie handling by introducing CookieResult and updating get_cookies_from_browser to return structured results --- .github/workflows/ci.yml | 1 - pyfragment/__init__.py | 2 ++ pyfragment/client.py | 3 +- pyfragment/methods/anonymous_number.py | 10 ++++-- pyfragment/methods/giveaway_premium.py | 2 +- pyfragment/methods/giveaway_stars.py | 2 +- pyfragment/methods/recharge_ads.py | 2 +- pyfragment/methods/search_gifts.py | 3 +- pyfragment/methods/search_numbers.py | 3 +- pyfragment/methods/search_usernames.py | 3 +- pyfragment/types/__init__.py | 2 ++ pyfragment/types/constants.py | 2 +- pyfragment/types/results.py | 17 ++++++++++ pyfragment/utils/__init__.py | 3 +- pyfragment/utils/cookies.py | 32 ++++++++++++++---- pyfragment/utils/wallet.py | 3 +- tests/014_test_cookies.py | 46 ++++++++++++++++++-------- 17 files changed, 99 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7615c5..1bf260b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,6 @@ jobs: runs-on: ubuntu-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - COOKIES_JSON: ${{ secrets.COOKIES_JSON }} strategy: fail-fast: false diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index 495897d..daee3af 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -13,6 +13,7 @@ from pyfragment.types import ( ClientError, ConfigurationError, CookieError, + CookieResult, FragmentAPIError, FragmentError, FragmentPageError, @@ -55,6 +56,7 @@ __all__ = [ "ClientError", "ConfigurationError", "CookieError", + "CookieResult", "FragmentAPIError", "FragmentError", "FragmentPageError", diff --git a/pyfragment/client.py b/pyfragment/client.py index d5f7e68..64433cf 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -21,7 +21,9 @@ from pyfragment.types import ( GiftsResult, LoginCodeResult, NumbersResult, + PremiumGiveawayResult, PremiumResult, + StarsGiveawayResult, StarsResult, TerminateSessionsResult, UsernamesResult, @@ -34,7 +36,6 @@ from pyfragment.types.constants import ( 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 diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/methods/anonymous_number.py index 41c317b..5d1786a 100644 --- a/pyfragment/methods/anonymous_number.py +++ b/pyfragment/methods/anonymous_number.py @@ -1,9 +1,15 @@ import html from typing import TYPE_CHECKING -from pyfragment.types import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types import ( + AnonymousNumberError, + FragmentAPIError, + FragmentError, + LoginCodeResult, + TerminateSessionsResult, + UnexpectedError, +) from pyfragment.types.constants import NUMBERS_PAGE -from pyfragment.types.results import LoginCodeResult, TerminateSessionsResult from pyfragment.utils import parse_login_code if TYPE_CHECKING: diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py index d152b62..681dab6 100644 --- a/pyfragment/methods/giveaway_premium.py +++ b/pyfragment/methods/giveaway_premium.py @@ -5,12 +5,12 @@ from pyfragment.types import ( ConfigurationError, FragmentAPIError, FragmentError, + PremiumGiveawayResult, UnexpectedError, UserNotFoundError, VerificationError, ) from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE -from pyfragment.types.results import PremiumGiveawayResult from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: diff --git a/pyfragment/methods/giveaway_stars.py b/pyfragment/methods/giveaway_stars.py index ab70340..0c802c4 100644 --- a/pyfragment/methods/giveaway_stars.py +++ b/pyfragment/methods/giveaway_stars.py @@ -5,12 +5,12 @@ from pyfragment.types import ( ConfigurationError, FragmentAPIError, FragmentError, + StarsGiveawayResult, UnexpectedError, UserNotFoundError, VerificationError, ) from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE -from pyfragment.types.results import StarsGiveawayResult from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: diff --git a/pyfragment/methods/recharge_ads.py b/pyfragment/methods/recharge_ads.py index 37bf3bf..8522bc8 100644 --- a/pyfragment/methods/recharge_ads.py +++ b/pyfragment/methods/recharge_ads.py @@ -2,6 +2,7 @@ import json from typing import TYPE_CHECKING from pyfragment.types import ( + AdsRechargeResult, ConfigurationError, FragmentAPIError, FragmentError, @@ -9,7 +10,6 @@ from pyfragment.types import ( VerificationError, ) from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE -from pyfragment.types.results import AdsRechargeResult from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: diff --git a/pyfragment/methods/search_gifts.py b/pyfragment/methods/search_gifts.py index f871eb1..f86108a 100644 --- a/pyfragment/methods/search_gifts.py +++ b/pyfragment/methods/search_gifts.py @@ -1,8 +1,7 @@ from typing import TYPE_CHECKING, Any -from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types import FragmentAPIError, FragmentError, GiftsResult, UnexpectedError from pyfragment.types.constants import GIFTS_PAGE -from pyfragment.types.results import GiftsResult from pyfragment.utils import parse_gift_items if TYPE_CHECKING: diff --git a/pyfragment/methods/search_numbers.py b/pyfragment/methods/search_numbers.py index d8af150..5969ac6 100644 --- a/pyfragment/methods/search_numbers.py +++ b/pyfragment/methods/search_numbers.py @@ -1,8 +1,7 @@ from typing import TYPE_CHECKING, Any -from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types import FragmentAPIError, FragmentError, NumbersResult, UnexpectedError from pyfragment.types.constants import NUMBERS_PAGE -from pyfragment.types.results import NumbersResult from pyfragment.utils import parse_auction_rows if TYPE_CHECKING: diff --git a/pyfragment/methods/search_usernames.py b/pyfragment/methods/search_usernames.py index 83d8872..6abdfed 100644 --- a/pyfragment/methods/search_usernames.py +++ b/pyfragment/methods/search_usernames.py @@ -1,8 +1,7 @@ from typing import TYPE_CHECKING, Any -from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError, UsernamesResult from pyfragment.types.constants import FRAGMENT_BASE_URL -from pyfragment.types.results import UsernamesResult from pyfragment.utils import parse_auction_rows if TYPE_CHECKING: diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 0563be0..52526a6 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -17,6 +17,7 @@ from pyfragment.types.exceptions import ( from pyfragment.types.results import ( AdsRechargeResult, AdsTopupResult, + CookieResult, GiftsResult, LoginCodeResult, NumbersResult, @@ -49,6 +50,7 @@ __all__ = [ # result types "AdsRechargeResult", "AdsTopupResult", + "CookieResult", "GiftsResult", "LoginCodeResult", "NumbersResult", diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index 115a73c..fa7cb76 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -54,7 +54,7 @@ DEVICE: str = json.dumps( { "platform": "iphone", "appName": "Tonkeeper", - "appVersion": "5.5.2", + "appVersion": "26.04.0", "maxProtocolVersion": 2, "features": [ "SendTransaction", diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index 89eb403..3f008e1 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -2,6 +2,23 @@ from dataclasses import dataclass from typing import Any +@dataclass +class CookieResult: + """Result returned by :func:`~pyfragment.utils.get_cookies_from_browser`. + + Attributes: + cookies: Dict with the four required Fragment cookie keys. + expires: Expiry of the ``stel_ssid`` session cookie in ISO 8601 format (UTC), + or ``None`` for session cookies. + """ + + cookies: dict[str, str] + expires: str | None + + def __repr__(self) -> str: + return f"CookieResult(expires={self.expires!r})" + + @dataclass class WalletInfo: """Wallet state returned by :meth:`FragmentClient.get_wallet`.""" diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index 843a2fa..45e96d7 100644 --- a/pyfragment/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,4 +1,4 @@ -from pyfragment.utils.cookies import get_cookies_from_browser +from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser 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 ( @@ -12,6 +12,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", + "CookieResult", "get_cookies_from_browser", "parse_auction_rows", "parse_gift_items", diff --git a/pyfragment/utils/cookies.py b/pyfragment/utils/cookies.py index 77f2205..565aa1f 100644 --- a/pyfragment/utils/cookies.py +++ b/pyfragment/utils/cookies.py @@ -1,16 +1,19 @@ from __future__ import annotations +from datetime import datetime, timezone + import rookiepy -from pyfragment.types import CookieError +from pyfragment.types import CookieError, CookieResult from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS -def get_cookies_from_browser(browser: str = "chrome") -> dict[str, str]: +def get_cookies_from_browser(browser: str = "chrome") -> CookieResult: """Extract Fragment session cookies directly from an installed browser. Reads the browser's on-disk cookie store (no extension required) and - returns the four cookies required by :class:`~pyfragment.FragmentClient`. + returns the four cookies required by :class:`~pyfragment.FragmentClient` + along with the session expiry timestamp. Args: browser: Browser name to read cookies from — case-insensitive. Supported values: @@ -19,8 +22,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> dict[str, str]: ``"firefox_based"``, ``"vivaldi"``, ``"librewolf"``, ``"safari"``. Returns: - A dict with the four required Fragment cookie keys: - ``stel_ssid``, ``stel_dt``, ``stel_token``, ``stel_ton_token``. + :class:`CookieResult` with ``.cookies`` (dict) and ``.expires`` (ISO 8601 string or ``None``). Raises: CookieError: If the browser is not supported, cookies cannot be read, @@ -42,4 +44,22 @@ def get_cookies_from_browser(browser: str = "chrome") -> dict[str, str]: if missing: raise CookieError(CookieError.MISSING_BROWSER_KEYS.format(browser=browser, keys=missing, url=FRAGMENT_BASE_URL)) - return {k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS} + expires_iso: str | None = None + for cookie in jar: + if cookie.get("name") == "stel_ssid": + raw = cookie.get("expires") + if isinstance(raw, (int, float)): + expires_iso = datetime.fromtimestamp(raw, tz=timezone.utc).isoformat() + elif isinstance(raw, str) and raw: + for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"): + try: + expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc).isoformat() + break + except ValueError: + continue + break + + return CookieResult( + cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, + expires=expires_iso, + ) diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 7abd120..4d67059 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -7,9 +7,8 @@ from ton_core import NetworkGlobalID from tonutils.clients import TonapiClient from tonutils.exceptions import ProviderResponseError -from pyfragment.types import TransactionError, WalletError +from pyfragment.types import TransactionError, WalletError, WalletInfo from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES -from pyfragment.types.results import WalletInfo from pyfragment.utils.decoder import clean_decode if TYPE_CHECKING: diff --git a/tests/014_test_cookies.py b/tests/014_test_cookies.py index e0b7d94..27e4c03 100644 --- a/tests/014_test_cookies.py +++ b/tests/014_test_cookies.py @@ -9,7 +9,7 @@ from pyfragment.types.constants import REQUIRED_COOKIE_KEYS from pyfragment.utils import get_cookies_from_browser FAKE_JAR = [ - {"name": "stel_ssid", "value": "abc123", "domain": "fragment.com"}, + {"name": "stel_ssid", "value": "abc123", "domain": "fragment.com", "expires": "2027-04-03T20:52:16.375Z"}, {"name": "stel_dt", "value": "-120", "domain": "fragment.com"}, {"name": "stel_token", "value": "tok_xyz", "domain": "fragment.com"}, {"name": "stel_ton_token", "value": "ton_xyz", "domain": "fragment.com"}, @@ -23,6 +23,9 @@ def _mock_rookiepy(jar: list[dict] | None = None) -> MagicMock: return mock +PATCH = "pyfragment.utils.cookies.rookiepy" + + # unsupported browser tests @@ -35,20 +38,35 @@ def test_unsupported_browser_raises() -> None: def test_returns_required_keys_only() -> None: - with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy()}): + with patch(PATCH, _mock_rookiepy()): result = get_cookies_from_browser("chrome") - assert set(result.keys()) == set(REQUIRED_COOKIE_KEYS) - assert result["stel_ssid"] == "abc123" - assert result["stel_dt"] == "-120" - assert result["stel_token"] == "tok_xyz" - assert result["stel_ton_token"] == "ton_xyz" - assert "unrelated" not in result + assert set(result.cookies.keys()) == set(REQUIRED_COOKIE_KEYS) + assert result.cookies["stel_ssid"] == "abc123" + assert result.cookies["stel_dt"] == "-120" + assert result.cookies["stel_token"] == "tok_xyz" + assert result.cookies["stel_ton_token"] == "ton_xyz" + assert "unrelated" not in result.cookies + + +def test_returns_expiry() -> None: + with patch(PATCH, _mock_rookiepy()): + result = get_cookies_from_browser("chrome") + + assert result.expires == "2027-04-03T20:52:16.375000+00:00" + + +def test_returns_none_expiry_when_missing() -> None: + jar = [{"name": k, "value": "v"} for k in REQUIRED_COOKIE_KEYS] + with patch(PATCH, _mock_rookiepy(jar)): + result = get_cookies_from_browser("chrome") + + assert result.expires is None def test_default_browser_is_chrome() -> None: mock_rp = _mock_rookiepy() - with patch.dict("sys.modules", {"rookiepy": mock_rp}): + with patch(PATCH, mock_rp): get_cookies_from_browser() mock_rp.chrome.assert_called_once_with(["fragment.com"]) @@ -56,10 +74,10 @@ def test_default_browser_is_chrome() -> None: def test_browser_name_is_case_insensitive() -> None: mock_rp = _mock_rookiepy() - with patch.dict("sys.modules", {"rookiepy": mock_rp}): + with patch(PATCH, mock_rp): result = get_cookies_from_browser("Chrome") - assert result["stel_ssid"] == "abc123" + assert result.cookies["stel_ssid"] == "abc123" # missing cookies tests @@ -71,7 +89,7 @@ def test_missing_cookies_raises() -> None: {"name": "stel_dt", "value": "-120"}, # stel_token and stel_ton_token missing ] - with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy(partial_jar)}): + with patch(PATCH, _mock_rookiepy(partial_jar)): with pytest.raises(CookieError, match="Fragment cookies not found in chrome"): get_cookies_from_browser("chrome") @@ -83,7 +101,7 @@ def test_empty_cookie_value_treated_as_missing() -> None: {"name": "stel_token", "value": "tok_xyz"}, {"name": "stel_ton_token", "value": "ton_xyz"}, ] - with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy(jar_with_empty)}): + with patch(PATCH, _mock_rookiepy(jar_with_empty)): with pytest.raises(CookieError, match="Fragment cookies not found in chrome"): get_cookies_from_browser("chrome") @@ -94,6 +112,6 @@ def test_empty_cookie_value_treated_as_missing() -> None: def test_browser_read_error_raises() -> None: mock_rp = MagicMock() mock_rp.chrome.side_effect = PermissionError("locked") - with patch.dict("sys.modules", {"rookiepy": mock_rp}): + with patch(PATCH, mock_rp): with pytest.raises(CookieError, match="Failed to read chrome cookies"): get_cookies_from_browser("chrome") From 028984155ba76b1af1b237f7bdc7935fdc5c5ccc Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 01:30:52 +0300 Subject: [PATCH 08/12] fix: update Python version matrix in CI workflow to include 3.10, 3.11, and 3.12 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bf260b..78a4c53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v6.0.2 From 6b6ca37f1040c728d0b7d99e2a888fb7709699ac Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 01:34:27 +0300 Subject: [PATCH 09/12] fix: update Python version requirements to support 3.10 and adjust CI workflow --- .github/workflows/ci.yml | 2 +- README.md | 4 ++-- pyfragment/client.py | 2 ++ pyfragment/methods/anonymous_number.py | 2 ++ pyfragment/methods/giveaway_premium.py | 2 ++ pyfragment/methods/giveaway_stars.py | 2 ++ pyfragment/methods/purchase_premium.py | 2 ++ pyfragment/methods/purchase_stars.py | 2 ++ pyfragment/methods/recharge_ads.py | 2 ++ pyfragment/methods/search_gifts.py | 2 ++ pyfragment/methods/search_numbers.py | 2 ++ pyfragment/methods/search_usernames.py | 2 ++ pyfragment/methods/topup_ton.py | 2 ++ pyfragment/types/constants.py | 2 ++ pyfragment/types/exceptions.py | 3 +++ pyfragment/types/results.py | 2 ++ pyfragment/utils/decoder.py | 2 ++ pyfragment/utils/html.py | 2 ++ pyfragment/utils/http.py | 2 ++ pyfragment/utils/wallet.py | 2 ++ pyproject.toml | 4 +++- 21 files changed, 43 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78a4c53..cfbc581 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] # 3.13, 3.14 are not supported by some dependencies yet steps: - uses: actions/checkout@v6.0.2 diff --git a/README.md b/README.md index 3d31da0..eb16129 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) [![PyPI downloads](https://img.shields.io/pypi/dm/pyfragment?style=flat&color=brightgreen)](https://pypi.org/project/pyfragment/) -[![Python](https://img.shields.io/badge/Python-3.12+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat&logo=python&logoColor=white)](https://python.org) [![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=lightgrey)](LICENSE) [![Stars](https://img.shields.io/github/stars/bohd4nx/pyfragment?style=flat&color=yellow)](https://github.com/bohd4nx/pyfragment/stargazers) [![CI](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions) @@ -34,7 +34,7 @@ To install the latest unreleased changes from the `dev` branch: pip install git+https://github.com/bohd4nx/pyfragment.git@dev ``` -Requires Python 3.12+. +Requires Python 3.10+. --- diff --git a/pyfragment/client.py b/pyfragment/client.py index 64433cf..a085c09 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from typing import Any, cast diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/methods/anonymous_number.py index 5d1786a..590a8e9 100644 --- a/pyfragment/methods/anonymous_number.py +++ b/pyfragment/methods/anonymous_number.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import html from typing import TYPE_CHECKING diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py index 681dab6..4a6d9b4 100644 --- a/pyfragment/methods/giveaway_premium.py +++ b/pyfragment/methods/giveaway_premium.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from typing import TYPE_CHECKING diff --git a/pyfragment/methods/giveaway_stars.py b/pyfragment/methods/giveaway_stars.py index 0c802c4..0788bb5 100644 --- a/pyfragment/methods/giveaway_stars.py +++ b/pyfragment/methods/giveaway_stars.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from typing import TYPE_CHECKING diff --git a/pyfragment/methods/purchase_premium.py b/pyfragment/methods/purchase_premium.py index 5b81ae4..3130205 100644 --- a/pyfragment/methods/purchase_premium.py +++ b/pyfragment/methods/purchase_premium.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import time from typing import TYPE_CHECKING diff --git a/pyfragment/methods/purchase_stars.py b/pyfragment/methods/purchase_stars.py index be71352..cbf3db0 100644 --- a/pyfragment/methods/purchase_stars.py +++ b/pyfragment/methods/purchase_stars.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from typing import TYPE_CHECKING diff --git a/pyfragment/methods/recharge_ads.py b/pyfragment/methods/recharge_ads.py index 8522bc8..ffe1fcf 100644 --- a/pyfragment/methods/recharge_ads.py +++ b/pyfragment/methods/recharge_ads.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from typing import TYPE_CHECKING diff --git a/pyfragment/methods/search_gifts.py b/pyfragment/methods/search_gifts.py index f86108a..2623ee7 100644 --- a/pyfragment/methods/search_gifts.py +++ b/pyfragment/methods/search_gifts.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Any from pyfragment.types import FragmentAPIError, FragmentError, GiftsResult, UnexpectedError diff --git a/pyfragment/methods/search_numbers.py b/pyfragment/methods/search_numbers.py index 5969ac6..83442bb 100644 --- a/pyfragment/methods/search_numbers.py +++ b/pyfragment/methods/search_numbers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Any from pyfragment.types import FragmentAPIError, FragmentError, NumbersResult, UnexpectedError diff --git a/pyfragment/methods/search_usernames.py b/pyfragment/methods/search_usernames.py index 6abdfed..3218ed5 100644 --- a/pyfragment/methods/search_usernames.py +++ b/pyfragment/methods/search_usernames.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Any from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError, UsernamesResult diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/methods/topup_ton.py index f19e342..0967ee8 100644 --- a/pyfragment/methods/topup_ton.py +++ b/pyfragment/methods/topup_ton.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from typing import TYPE_CHECKING diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index fa7cb76..d552234 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from typing import Any, Literal, get_args diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 3027d74..f2db392 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -1,3 +1,6 @@ +from __future__ import annotations + + class FragmentError(Exception): """Base exception for all pyfragment library errors.""" diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index 3f008e1..1e4cecc 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass from typing import Any diff --git a/pyfragment/utils/decoder.py b/pyfragment/utils/decoder.py index 5acae41..d38a692 100644 --- a/pyfragment/utils/decoder.py +++ b/pyfragment/utils/decoder.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import base64 from ton_core import Cell diff --git a/pyfragment/utils/html.py b/pyfragment/utils/html.py index f3a82e5..28defd7 100644 --- a/pyfragment/utils/html.py +++ b/pyfragment/utils/html.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import re from typing import Any diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py index cffc227..9cac3fd 100644 --- a/pyfragment/utils/http.py +++ b/pyfragment/utils/http.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import re from typing import Any diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 4d67059..039f482 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import base64 import ssl diff --git a/pyproject.toml b/pyproject.toml index f0218c2..2579e64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "2026.2.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" +requires-python = ">=3.10" authors = [{ name = "bohd4nx", url = "https://github.com/bohd4nx" }] keywords = ["fragment", "telegram", "ton", "stars", "premium", "crypto", "blockchain"] classifiers = [ @@ -18,6 +18,8 @@ classifiers = [ "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Framework :: AsyncIO", "Topic :: Software Development :: Libraries :: Python Modules", From d81b83ac91f9c17aafc664849023c9ddd5508a76 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 01:37:52 +0300 Subject: [PATCH 10/12] fix: reorder import statements in conftest.py for better organization --- tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index a05bfd4..aa056b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,12 @@ import os import pytest +import pyfragment.methods.giveaway_premium # noqa: F401 +import pyfragment.methods.giveaway_stars # noqa: F401 +import pyfragment.methods.purchase_premium # noqa: F401 +import pyfragment.methods.purchase_stars # noqa: F401 +import pyfragment.methods.recharge_ads # noqa: F401 +import pyfragment.methods.topup_ton # noqa: F401 from pyfragment import FragmentClient from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED From 6a5e007a1be15f3e1631e67c3759e9fb8e96833f Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 01:45:04 +0300 Subject: [PATCH 11/12] refactor: update import statements and patching methods in test files for better modularity --- tests/004_test_stars.py | 11 +++++++---- tests/005_test_premium.py | 11 +++++++---- tests/006_test_topup.py | 6 ++++-- tests/010_test_recharge_ads.py | 6 ++++-- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index 9ec952e..e550c3c 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -1,9 +1,12 @@ """Unit tests for Stars methods — purchase_stars and giveaway_stars.""" +import importlib from unittest.mock import AsyncMock, patch import pytest +_purchase_stars_mod = importlib.import_module("pyfragment.methods.purchase_stars") +_giveaway_stars_mod = importlib.import_module("pyfragment.methods.giveaway_stars") from pyfragment import FragmentClient from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH @@ -46,8 +49,8 @@ async def test_purchase_stars_success(client: FragmentClient) -> None: ] ), ), - patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.purchase_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + patch.object(_purchase_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch.object(_purchase_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.purchase_stars("@user", amount=500) @@ -120,8 +123,8 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None: ] ), ), - patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.giveaway_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + patch.object(_giveaway_stars_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch.object(_giveaway_stars_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.giveaway_stars("@channel", winners=3, amount=1000) diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index 54283d3..ba58d55 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -1,9 +1,12 @@ """Unit tests for Premium methods — purchase_premium and giveaway_premium.""" +import importlib from unittest.mock import AsyncMock, patch import pytest +_purchase_premium_mod = importlib.import_module("pyfragment.methods.purchase_premium") +_giveaway_premium_mod = importlib.import_module("pyfragment.methods.giveaway_premium") from pyfragment import FragmentClient from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH @@ -41,8 +44,8 @@ async def test_purchase_premium_success(client: FragmentClient) -> None: ] ), ), - patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.purchase_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + patch.object(_purchase_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch.object(_purchase_premium_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.purchase_premium("@user", months=3) @@ -103,8 +106,8 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None: ] ), ), - patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.giveaway_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + patch.object(_giveaway_premium_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch.object(_giveaway_premium_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.giveaway_premium("@channel", winners=10, months=3) diff --git a/tests/006_test_topup.py b/tests/006_test_topup.py index 67dbd7a..f1aede7 100644 --- a/tests/006_test_topup.py +++ b/tests/006_test_topup.py @@ -1,9 +1,11 @@ """Unit tests for topup_ton — TON Ads balance top-up.""" +import importlib from unittest.mock import AsyncMock, patch import pytest +_topup_ton_mod = importlib.import_module("pyfragment.methods.topup_ton") from pyfragment import FragmentClient from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH @@ -47,8 +49,8 @@ async def test_topup_ton_success(client: FragmentClient) -> None: ] ), ), - patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.topup_ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + patch.object(_topup_ton_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch.object(_topup_ton_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.topup_ton("@user", amount=10) diff --git a/tests/010_test_recharge_ads.py b/tests/010_test_recharge_ads.py index 2c9300c..f0e090c 100644 --- a/tests/010_test_recharge_ads.py +++ b/tests/010_test_recharge_ads.py @@ -1,9 +1,11 @@ """Unit tests for recharge_ads — self-service Telegram Ads recharge.""" +import importlib from unittest.mock import AsyncMock, patch import pytest +_recharge_ads_mod = importlib.import_module("pyfragment.methods.recharge_ads") from pyfragment import FragmentClient from pyfragment.types import AdsRechargeResult, ConfigurationError from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH @@ -46,8 +48,8 @@ async def test_recharge_ads_success(client: FragmentClient) -> None: ] ), ), - patch("pyfragment.methods.recharge_ads.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.recharge_ads.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + patch.object(_recharge_ads_mod, "get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch.object(_recharge_ads_mod, "process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10) From 415d9d9a9c987d182f0936df72c8ef12a5c31ad9 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 01:57:12 +0300 Subject: [PATCH 12/12] docs: update examples and changelog to reflect CookieResult usage and Python version changes --- CHANGELOG.md | 8 ++++++-- README.md | 4 +++- examples/auctions/search_gifts.py | 2 +- examples/auctions/search_numbers.py | 2 +- examples/auctions/search_usernames.py | 2 +- examples/client/raw_api_call.py | 2 +- examples/client/wallet_info.py | 2 +- examples/numbers/manage_number.py | 2 +- examples/purchase/recharge_ads_balance.py | 2 +- examples/purchase/run_premium_giveaway.py | 2 +- examples/purchase/run_stars_giveaway.py | 2 +- examples/purchase/send_premium.py | 2 +- examples/purchase/send_stars.py | 2 +- examples/purchase/topup_ton_balance.py | 2 +- 14 files changed, 21 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f85d7f..ab4d62d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,13 +14,17 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `get_cookies_from_browser(browser)` — extract Fragment session cookies directly from an installed browser (Chrome, Firefox, Edge, Brave, Arc, Opera, Safari, and more); no browser extension or manual copy-paste required ```python from pyfragment.utils import get_cookies_from_browser - cookies = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... - client = FragmentClient(seed="...", api_key="...", cookies=cookies) + result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + client = FragmentClient(seed="...", api_key="...", cookies=result.cookies) + print(result.expires) # ISO 8601 expiry of stel_ssid, or None for session cookies ``` +- `CookieResult` — return type of `get_cookies_from_browser()`; exposes `.cookies` (`dict[str, str]`) and `.expires` (ISO 8601 string or `None`) ### Changed +- `DEVICE` Tonkeeper fingerprint updated: `appVersion` → `26.04.0` - `tonutils` upgraded to **2.1.0** +- Minimum Python version lowered to **3.10** (previously 3.12) --- diff --git a/README.md b/README.md index eb16129..bb94671 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ Requires Python 3.10+. - **Automatically** (recommended) — use `get_cookies_from_browser()`, which reads them directly from your browser's on-disk store. No extension needed: ```python from pyfragment.utils import get_cookies_from_browser - cookies = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + result = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... + # result.cookies — dict[str, str] to pass to FragmentClient + # result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies ``` - **Manually** — install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or JSON string. diff --git a/examples/auctions/search_gifts.py b/examples/auctions/search_gifts.py index 4eb31ac..dafcaac 100644 --- a/examples/auctions/search_gifts.py +++ b/examples/auctions/search_gifts.py @@ -17,7 +17,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = { diff --git a/examples/auctions/search_numbers.py b/examples/auctions/search_numbers.py index f8d55ba..f3f5a36 100644 --- a/examples/auctions/search_numbers.py +++ b/examples/auctions/search_numbers.py @@ -16,7 +16,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = { diff --git a/examples/auctions/search_usernames.py b/examples/auctions/search_usernames.py index f478b03..5fc3e13 100644 --- a/examples/auctions/search_usernames.py +++ b/examples/auctions/search_usernames.py @@ -16,7 +16,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = { diff --git a/examples/client/raw_api_call.py b/examples/client/raw_api_call.py index 070ee7a..4996d90 100644 --- a/examples/client/raw_api_call.py +++ b/examples/client/raw_api_call.py @@ -18,7 +18,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = { diff --git a/examples/client/wallet_info.py b/examples/client/wallet_info.py index 4b4f14f..5ac628f 100644 --- a/examples/client/wallet_info.py +++ b/examples/client/wallet_info.py @@ -14,7 +14,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = { diff --git a/examples/numbers/manage_number.py b/examples/numbers/manage_number.py index 8251c2e..80f1a3b 100644 --- a/examples/numbers/manage_number.py +++ b/examples/numbers/manage_number.py @@ -15,7 +15,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = { diff --git a/examples/purchase/recharge_ads_balance.py b/examples/purchase/recharge_ads_balance.py index 56dde7a..d0084bc 100644 --- a/examples/purchase/recharge_ads_balance.py +++ b/examples/purchase/recharge_ads_balance.py @@ -19,7 +19,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = { diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py index c152af9..d0a39b8 100644 --- a/examples/purchase/run_premium_giveaway.py +++ b/examples/purchase/run_premium_giveaway.py @@ -14,7 +14,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = { diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py index a9188ba..5a73310 100644 --- a/examples/purchase/run_stars_giveaway.py +++ b/examples/purchase/run_stars_giveaway.py @@ -14,7 +14,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = { diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py index cd12734..f82f1ed 100644 --- a/examples/purchase/send_premium.py +++ b/examples/purchase/send_premium.py @@ -14,7 +14,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = { diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py index 73d77eb..76b616a 100644 --- a/examples/purchase/send_stars.py +++ b/examples/purchase/send_stars.py @@ -14,7 +14,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = { diff --git a/examples/purchase/topup_ton_balance.py b/examples/purchase/topup_ton_balance.py index 49900b7..f09d0f3 100644 --- a/examples/purchase/topup_ton_balance.py +++ b/examples/purchase/topup_ton_balance.py @@ -21,7 +21,7 @@ SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" # Option A: extract cookies directly from your browser (no manual copy-paste needed) -# COOKIES = get_cookies_from_browser("chrome") # or "firefox", "edge", "brave", ... +# COOKIES = get_cookies_from_browser("chrome").cookies # or "firefox", "edge", "brave", ... # Option B: provide cookies manually COOKIES = {