diff --git a/examples/purchase/run_premium_giveaway.py b/examples/purchase/run_premium_giveaway.py index 1aefd83..115fa1d 100644 --- a/examples/purchase/run_premium_giveaway.py +++ b/examples/purchase/run_premium_giveaway.py @@ -28,7 +28,7 @@ COOKIES = { CHANNEL = "https://t.me/channel" WINNERS = 10 # 1–24 000 MONTHS = 3 # 3, 6 or 12 -PAYMENT_METHOD = PaymentMethod.TON # PaymentMethod.TON or PaymentMethod.USDT_TON +PAYMENT_METHOD = PaymentMethod.TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/examples/purchase/run_stars_giveaway.py b/examples/purchase/run_stars_giveaway.py index 47b124c..ea4d886 100644 --- a/examples/purchase/run_stars_giveaway.py +++ b/examples/purchase/run_stars_giveaway.py @@ -28,7 +28,7 @@ COOKIES = { CHANNEL = "https://t.me/channel" WINNERS = 3 # 1–15 AMOUNT = 1000 # 500–1 000 000 stars per winner -PAYMENT_METHOD = PaymentMethod.USDT_TON # PaymentMethod.TON or PaymentMethod.USDT_TON +PAYMENT_METHOD = PaymentMethod.USDT_TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/examples/purchase/send_premium.py b/examples/purchase/send_premium.py index 9cd1f98..f215f45 100644 --- a/examples/purchase/send_premium.py +++ b/examples/purchase/send_premium.py @@ -27,7 +27,7 @@ COOKIES = { USERNAME = "https://t.me/username" MONTHS = 3 # 3, 6 or 12 -PAYMENT_METHOD = PaymentMethod.TON # PaymentMethod.TON or PaymentMethod.USDT_TON +PAYMENT_METHOD = PaymentMethod.TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/examples/purchase/send_stars.py b/examples/purchase/send_stars.py index e8181c3..f490e65 100644 --- a/examples/purchase/send_stars.py +++ b/examples/purchase/send_stars.py @@ -27,7 +27,7 @@ COOKIES = { USERNAME = "https://t.me/username" AMOUNT = 500 # 50–10 000 000 stars -PAYMENT_METHOD = PaymentMethod.USDT_TON # PaymentMethod.TON or PaymentMethod.USDT_TON +PAYMENT_METHOD = PaymentMethod.USDT_TON # TON, USDT_TON, USDT_ETH, USDT_POL, USDC_ETH, USDC_BASE, USDC_POL async def main() -> None: diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index fc9d194..afdedfd 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -4,6 +4,7 @@ from importlib.metadata import version from pyfragment.client import FragmentClient from pyfragment.core.cookies import get_cookies_from_browser from pyfragment.exceptions import ( + AlreadySubscribedError, AnonymousNumberError, ClientError, ConfigurationError, @@ -53,6 +54,7 @@ __all__ = [ "FragmentAPIError", "FragmentPageError", "ConfigurationError", + "AlreadySubscribedError", "UserNotFoundError", "WalletError", "VerificationError", diff --git a/pyfragment/client.py b/pyfragment/client.py index d47e5d0..2a90711 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -148,7 +148,9 @@ class FragmentClient: username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. months: Duration — ``3``, ``6``, or ``12``. show_sender: Show your name as the sender. Defaults to ``True``. - payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. + payment_method: Payment currency — ``"ton"`` (default), ``"usdt_ton"``, + ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, + or ``"usdc_pol"``. Returns: :class:`PremiumResult` with ``transaction_id``, ``username``, and ``amount``. @@ -168,7 +170,9 @@ class FragmentClient: username: Recipient identifier — ``@username``, ``username``, or ``https://t.me/username``. amount: Number of stars — integer from ``50`` to ``10 000 000``. show_sender: Show your name as the gift sender. Defaults to ``True``. - payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. + payment_method: Payment currency — ``"ton"`` (default), ``"usdt_ton"``, + ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, + or ``"usdc_pol"``. Returns: :class:`StarsResult` with ``transaction_id``, ``username``, and ``amount``. @@ -224,7 +228,9 @@ class FragmentClient: channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. winners: Number of winners — integer from ``1`` to ``15``. amount: Stars each winner receives — integer from ``500`` to ``1 000 000``. - payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. + payment_method: Payment currency — ``"ton"`` (default), ``"usdt_ton"``, + ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, + or ``"usdc_pol"``. Returns: :class:`StarsGiveawayResult` with ``transaction_id``, ``channel``, @@ -245,7 +251,9 @@ class FragmentClient: channel: Channel identifier — ``@channel``, ``channel``, or ``https://t.me/channel``. winners: Number of winners — integer from ``1`` to ``24 000``. months: Premium duration per winner — ``3``, ``6``, or ``12``. Defaults to ``3``. - payment_method: Payment currency — ``"ton"`` (default) or ``"usdt_ton"``. + payment_method: Payment currency — ``"ton"`` (default), ``"usdt_ton"``, + ``"usdt_eth"``, ``"usdt_pol"``, ``"usdc_eth"``, ``"usdc_base"``, + or ``"usdc_pol"``. Returns: :class:`PremiumGiveawayResult` with ``transaction_id``, ``channel``, diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index d3ca352..b92adfb 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -38,6 +38,11 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def _state_nonce() -> str: + # Fragment expects a pseudo-random nonce-like dh value in giveaway state updates. + return str(random.randint(100_000_000, 2_147_483_647)) + + async def giveaway_stars( client: FragmentClient, channel: str, @@ -65,7 +70,12 @@ async def giveaway_stars( await client.call( "updateStarsGiveawayState", - {"mode": "new", "lv": "false", "dh": str(random.randint(100_000_000, 999_999_999))}, + {"mode": "new", "lv": "false", "dh": _state_nonce()}, + page_url=STARS_GIVEAWAY_PAGE, + ) + await client.call( + "updateStarsGiveawayPrices", + {"quantity": winners, "stars": amount}, page_url=STARS_GIVEAWAY_PAGE, ) @@ -162,11 +172,16 @@ async def giveaway_premium( { "mode": "new", "lv": "false", - "dh": str(random.randint(100_000_000, 999_999_999)), + "dh": _state_nonce(), "quantity": "", }, page_url=PREMIUM_GIVEAWAY_PAGE, ) + await client.call( + "updatePremiumGiveawayPrices", + {"quantity": winners}, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) result = await client.call( "initGiveawayPremiumRequest", diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index cf7b11d..20a8b4d 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -2,7 +2,7 @@ from __future__ import annotations import json import logging -import time +import random from typing import TYPE_CHECKING from pyfragment.core.constants import ( @@ -17,6 +17,7 @@ from pyfragment.domains.payments import parse_required_payment_amount from pyfragment.domains.tonapi.account import get_account_info from pyfragment.domains.tonapi.transaction import process_transaction from pyfragment.exceptions import ( + AlreadySubscribedError, ConfigurationError, FragmentAPIError, FragmentError, @@ -34,6 +35,11 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +def _state_nonce() -> str: + # Fragment accepts a pseudo-random request nonce in state update methods. + return str(random.randint(100_000_000, 2_147_483_647)) + + async def purchase_stars( client: FragmentClient, username: str, @@ -59,7 +65,7 @@ async def purchase_stars( await client.call( "updateStarsBuyState", - {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, + {"mode": "new", "lv": "false", "dh": _state_nonce()}, page_url=STARS_PAGE, ) result = await client.call( @@ -140,7 +146,7 @@ async def purchase_premium( await client.call( "updatePremiumState", - {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, + {"mode": "new", "lv": "false", "dh": _state_nonce()}, page_url=PREMIUM_PAGE, ) result = await client.call( @@ -148,6 +154,9 @@ async def purchase_premium( {"recipient": recipient, "months": months, "payment_method": payment_method}, page_url=PREMIUM_PAGE, ) + error_text = str(result.get("error", "")).strip().lower() + if "already subscribed to telegram premium" in error_text: + raise AlreadySubscribedError(AlreadySubscribedError.PREMIUM_ACTIVE) required_payment_amount = parse_required_payment_amount(result) req_id = result.get("req_id") if not req_id: diff --git a/pyfragment/exceptions.py b/pyfragment/exceptions.py index ac872e9..2b98d64 100644 --- a/pyfragment/exceptions.py +++ b/pyfragment/exceptions.py @@ -96,6 +96,12 @@ class UserNotFoundError(FragmentAPIError): ) +class AlreadySubscribedError(FragmentAPIError): + """Raised when trying to gift Premium to a user who already has an active subscription.""" + + PREMIUM_ACTIVE = "This account is already subscribed to Telegram Premium." + + class AnonymousNumberError(FragmentAPIError): """Raised for Fragment anonymous number API failures.""" @@ -167,6 +173,7 @@ __all__ = [ "FragmentAPIError", "FragmentPageError", "AnonymousNumberError", + "AlreadySubscribedError", "UserNotFoundError", "TransactionError", "ParseError", diff --git a/pyfragment/models/enums.py b/pyfragment/models/enums.py index 6f2ec8e..b9794b2 100644 --- a/pyfragment/models/enums.py +++ b/pyfragment/models/enums.py @@ -17,6 +17,11 @@ else: class PaymentMethod(StrEnum): TON = "ton" USDT_TON = "usdt_ton" + USDT_ETH = "usdt_eth" + USDT_POL = "usdt_pol" + USDC_ETH = "usdc_eth" + USDC_BASE = "usdc_base" + USDC_POL = "usdc_pol" class WalletVersion(StrEnum): diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index 49ec4a8..1dc6b08 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -7,6 +7,14 @@ import pytest import pyfragment.domains.giveaways.giveaway as _giveaway_stars_mod import pyfragment.domains.purchases.purchase as _purchase_stars_mod from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError +from pyfragment.core.constants.limits import ( + STARS_GIVEAWAY_MAX, + STARS_GIVEAWAY_MIN, + STARS_PURCHASE_MAX, + STARS_PURCHASE_MIN, + STARS_WINNERS_MAX, + STARS_WINNERS_MIN, +) from pyfragment.models.enums import PaymentMethod from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH @@ -16,13 +24,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSAC @pytest.mark.asyncio async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.purchase_stars("@user", amount=49) + await client.purchase_stars("@user", amount=STARS_PURCHASE_MIN - 1) @pytest.mark.asyncio async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.purchase_stars("@user", amount=1_000_001) + await client.purchase_stars("@user", amount=STARS_PURCHASE_MAX + 1) @pytest.mark.asyncio @@ -114,25 +122,25 @@ async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_giveaway_stars_winners_too_low(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_stars("@channel", winners=0, amount=500) + await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN - 1, amount=STARS_GIVEAWAY_MIN) @pytest.mark.asyncio async def test_giveaway_stars_winners_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_stars("@channel", winners=6, amount=500) + await client.giveaway_stars("@channel", winners=STARS_WINNERS_MAX + 1, amount=STARS_GIVEAWAY_MIN) @pytest.mark.asyncio async def test_giveaway_stars_amount_too_low(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_stars("@channel", winners=1, amount=499) + await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MIN - 1) @pytest.mark.asyncio async def test_giveaway_stars_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_stars("@channel", winners=1, amount=1_000_001) + await client.giveaway_stars("@channel", winners=STARS_WINNERS_MIN, amount=STARS_GIVEAWAY_MAX + 1) @pytest.mark.asyncio @@ -166,6 +174,7 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None: side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, {}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -189,6 +198,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, {}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -201,7 +211,7 @@ async def test_giveaway_stars_passes_payment_method(client: FragmentClient) -> N ): await client.giveaway_stars("@channel", winners=3, amount=1000, payment_method=PaymentMethod.USDT_TON) - init_call = call_mock.await_args_list[2] + init_call = call_mock.await_args_list[3] assert init_call.args[0] == "initGiveawayStarsRequest" assert init_call.args[1]["payment_method"] == "usdt_ton" assert proc_mock.await_args is not None diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index 6667602..8ecae0c 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -7,6 +7,8 @@ import pytest import pyfragment.domains.giveaways.giveaway as _giveaway_premium_mod import pyfragment.domains.purchases.purchase as _purchase_premium_mod from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError +from pyfragment.core.constants.limits import PREMIUM_MONTHS_VALID, PREMIUM_WINNERS_MAX, PREMIUM_WINNERS_MIN +from pyfragment.exceptions import AlreadySubscribedError from pyfragment.models.enums import PaymentMethod from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH @@ -22,7 +24,7 @@ async def test_purchase_premium_invalid_months(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_purchase_premium_months_zero(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.purchase_premium("@user", months=0) + await client.purchase_premium("@user", months=min(PREMIUM_MONTHS_VALID) - 1) @pytest.mark.asyncio @@ -85,6 +87,25 @@ async def test_purchase_premium_passes_payment_method(client: FragmentClient) -> assert proc_mock.await_args.kwargs["payment_method"] == "usdt_ton" +@pytest.mark.asyncio +async def test_purchase_premium_already_subscribed_raises(client: FragmentClient) -> None: + with ( + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {}, # updatePremiumState + {"error": "This account is already subscribed to Telegram Premium."}, + ] + ), + ), + ): + with pytest.raises(AlreadySubscribedError): + await client.purchase_premium("@user", months=6) + + @pytest.mark.asyncio @pytest.mark.parametrize("query", ["@user", "monk", "https://t.me/monk"]) async def test_purchase_premium_accepts_query_formats(client: FragmentClient, query: str) -> None: @@ -111,13 +132,13 @@ async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_giveaway_premium_winners_too_low(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_premium("@channel", winners=0, months=3) + await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MIN - 1, months=3) @pytest.mark.asyncio async def test_giveaway_premium_winners_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.giveaway_premium("@channel", winners=24_001, months=3) + await client.giveaway_premium("@channel", winners=PREMIUM_WINNERS_MAX + 1, months=3) @pytest.mark.asyncio @@ -151,6 +172,7 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None: side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, {}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -174,6 +196,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, {}, + {}, {"req_id": FAKE_REQ_ID}, FAKE_TRANSACTION, ] @@ -186,7 +209,7 @@ async def test_giveaway_premium_passes_payment_method(client: FragmentClient) -> ): await client.giveaway_premium("@channel", winners=10, months=6, payment_method=PaymentMethod.USDT_TON) - init_call = call_mock.await_args_list[2] + init_call = call_mock.await_args_list[3] assert init_call.args[0] == "initGiveawayPremiumRequest" assert init_call.args[1]["payment_method"] == "usdt_ton" assert proc_mock.await_args is not None diff --git a/tests/006_test_topup.py b/tests/006_test_topup.py index f860ecc..e1807da 100644 --- a/tests/006_test_topup.py +++ b/tests/006_test_topup.py @@ -6,6 +6,7 @@ import pytest import pyfragment.domains.ads.tonup as _topup_ton_mod from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError +from pyfragment.core.constants.limits import TON_TOPUP_MAX, TON_TOPUP_MIN from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Topup TON validation tests @@ -14,13 +15,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSAC @pytest.mark.asyncio async def test_topup_ton_amount_zero(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.topup_ton("@user", amount=0) + await client.topup_ton("@user", amount=TON_TOPUP_MIN - 1) @pytest.mark.asyncio async def test_topup_ton_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.topup_ton("@user", amount=1_000_000_001) + await client.topup_ton("@user", amount=TON_TOPUP_MAX + 1) @pytest.mark.asyncio diff --git a/tests/010_test_recharge_ads.py b/tests/010_test_recharge_ads.py index 5feff7a..51f56a2 100644 --- a/tests/010_test_recharge_ads.py +++ b/tests/010_test_recharge_ads.py @@ -6,6 +6,7 @@ import pytest import pyfragment.domains.ads.recharge as _recharge_ads_mod from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient +from pyfragment.core.constants.limits import TON_TOPUP_MAX, TON_TOPUP_MIN from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # recharge_ads validation tests @@ -14,13 +15,13 @@ from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANS @pytest.mark.asyncio async def test_recharge_ads_amount_zero(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0) + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=TON_TOPUP_MIN - 1) @pytest.mark.asyncio async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): - await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001) + await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=TON_TOPUP_MAX + 1) @pytest.mark.asyncio