From 391d15221b125cf3e0a98db33b8dab5a8a50a242 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 01:25:25 +0300 Subject: [PATCH] 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")