mirror of
https://github.com/vibe-existing/pyfragment.git
synced 2026-07-25 06:54:31 +00:00
feat: implement recharge_ads method for Telegram Ads account funding; add associated result type and example; enhance tests for validation and success cases
This commit is contained in:
@@ -16,6 +16,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
||||
- `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each); raises `UserNotFoundError` if the channel is not found on Fragment, `ConfigurationError` if `winners` or `months` are invalid
|
||||
- `StarsGiveawayResult` and `PremiumGiveawayResult` result types
|
||||
|
||||
**Telegram Ads**
|
||||
- `recharge_ads(account, amount)` — add funds to your own Telegram Ads account; `account` is the channel or bot username linked to your Ads account; raises `ConfigurationError` if `amount` is out of range (1–1 000 000 000 TON)
|
||||
- `AdsRechargeResult` result type
|
||||
|
||||
**Raw API access**
|
||||
- `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update
|
||||
- `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers
|
||||
@@ -32,6 +36,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
||||
- `examples/giveaway_premium.py` — Premium giveaway with `UserNotFoundError` / `ConfigurationError` handling
|
||||
- `examples/call.py` — raw API call via `client.call()`
|
||||
- `examples/anonymous_number.py` — login code fetch and session termination with `AnonymousNumberError` handling
|
||||
- `examples/recharge_ads.py` — self-service Ads recharge with `ConfigurationError` / `WalletError` handling
|
||||
|
||||
### Changed
|
||||
- All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method
|
||||
@@ -39,6 +44,7 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
||||
- Method module files renamed to match their function: `premium.py` → `purchase_premium.py`, `stars.py` → `purchase_stars.py`, `ton.py` → `topup_ton.py`
|
||||
- `timestamp` field removed from all result dataclasses
|
||||
- All page URL constants (`STARS_PAGE`, `PREMIUM_PAGE`, etc.) now built from `FRAGMENT_BASE_URL` instead of hardcoded strings
|
||||
- `STARS_REVENUE_PAGE` and `ADS_PAGE` constants replaced by a single `ADS_TOPUP_PAGE` shared by both `topup_ton` and `recharge_ads`
|
||||
- `TransactionError` now includes an SSL hint when a broadcast fails due to certificate verification errors
|
||||
- `TransactionError.DUPLICATE_SEQNO` — dedicated error raised after 3 failed attempts due to a `406 Duplicate msg_seqno` response from the TON network; broadcast is automatically retried (up to 2 retries, 2 s apart) before giving up
|
||||
- All error message templates rewritten to follow a "what happened → why → what to do" pattern — every template is now actionable and includes a fix hint where applicable
|
||||
|
||||
+4
-3
@@ -4,8 +4,9 @@ Example: send a raw request to any Fragment API method.
|
||||
Use client.call() when you need to access a method that is not yet
|
||||
wrapped by the library, or to inspect raw API responses directly.
|
||||
|
||||
page_url must match the Fragment page the method belongs to — Fragment
|
||||
requires a hash derived from each specific page.
|
||||
page_url is optional — only set it when the target method belongs to a
|
||||
specific Fragment page (Fragment derives the API hash per page).
|
||||
Defaults to the Fragment base URL.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -23,7 +24,7 @@ COOKIES = {
|
||||
|
||||
METHOD = "anyFragmentMethod" # replace with the actual method name
|
||||
DATA = {"key": "value"} # replace with the actual request payload
|
||||
PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page
|
||||
PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page (optional)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Example: recharge your own Telegram Ads account with TON.
|
||||
|
||||
Amount must be an integer between 1 and 1 000 000 000 TON.
|
||||
Your wallet must hold at least the recharge amount + ~0.056 TON for gas.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient, WalletError
|
||||
|
||||
SEED = "word1 word2 ... word24"
|
||||
API_KEY = "YOUR_TONAPI_KEY"
|
||||
COOKIES = {
|
||||
"stel_ssid": "YOUR_STEL_SSID",
|
||||
"stel_dt": "YOUR_STEL_DT",
|
||||
"stel_token": "YOUR_STEL_TOKEN",
|
||||
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||
}
|
||||
|
||||
ACCOUNT = "@mychannel" # channel or bot username linked to your Telegram Ads account
|
||||
AMOUNT = 10 # 1–1 000 000 000 TON
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||
try:
|
||||
result: AdsRechargeResult = await client.recharge_ads(ACCOUNT, amount=AMOUNT)
|
||||
except WalletError as e:
|
||||
print(f"Wallet error — insufficient balance or misconfiguration: {e}")
|
||||
return
|
||||
except ConfigurationError as e:
|
||||
print(f"Invalid argument: {e}")
|
||||
return
|
||||
|
||||
print(f"{result.amount} TON recharged to Ads account {ACCOUNT} | tx: {result.transaction_id}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -7,6 +7,7 @@ from importlib.metadata import version
|
||||
|
||||
from pyfragment.client import FragmentClient
|
||||
from pyfragment.types import (
|
||||
AdsRechargeResult,
|
||||
AdsTopupResult,
|
||||
AnonymousNumberError,
|
||||
ClientError,
|
||||
@@ -36,6 +37,7 @@ __version__: str = version("pyfragment")
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"FragmentClient",
|
||||
"AdsRechargeResult",
|
||||
"AdsTopupResult",
|
||||
"LoginCodeResult",
|
||||
"PremiumGiveawayResult",
|
||||
|
||||
@@ -8,8 +8,10 @@ from pyfragment.methods.giveaway_premium import giveaway_premium
|
||||
from pyfragment.methods.giveaway_stars import giveaway_stars
|
||||
from pyfragment.methods.purchase_premium import purchase_premium
|
||||
from pyfragment.methods.purchase_stars import purchase_stars
|
||||
from pyfragment.methods.recharge_ads import recharge_ads
|
||||
from pyfragment.methods.topup_ton import topup_ton
|
||||
from pyfragment.types import (
|
||||
AdsRechargeResult,
|
||||
AdsTopupResult,
|
||||
ConfigurationError,
|
||||
CookieError,
|
||||
@@ -153,6 +155,19 @@ class FragmentClient:
|
||||
"""
|
||||
return await topup_ton(self, username, amount, show_sender)
|
||||
|
||||
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
|
||||
"""Add funds to your own Telegram Ads account.
|
||||
|
||||
Args:
|
||||
account: Your Fragment Ads account identifier — the channel or bot username
|
||||
the Ads account is linked to (e.g. ``"@mychannel"``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
|
||||
"""
|
||||
return await recharge_ads(self, account, amount)
|
||||
|
||||
async def get_wallet(self) -> WalletInfo:
|
||||
"""Return the address, state and balance of the TON wallet.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from pyfragment.methods.giveaway_premium import giveaway_premium
|
||||
from pyfragment.methods.giveaway_stars import giveaway_stars
|
||||
from pyfragment.methods.purchase_premium import purchase_premium
|
||||
from pyfragment.methods.purchase_stars import purchase_stars
|
||||
from pyfragment.methods.recharge_ads import recharge_ads
|
||||
from pyfragment.methods.topup_ton import topup_ton
|
||||
|
||||
__all__ = [
|
||||
@@ -11,6 +12,7 @@ __all__ = [
|
||||
"giveaway_stars",
|
||||
"purchase_premium",
|
||||
"purchase_stars",
|
||||
"recharge_ads",
|
||||
"terminate_sessions",
|
||||
"toggle_login_codes",
|
||||
"topup_ton",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from pyfragment.types import (
|
||||
ConfigurationError,
|
||||
FragmentAPIError,
|
||||
FragmentError,
|
||||
UnexpectedError,
|
||||
)
|
||||
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
||||
from pyfragment.types.results import AdsRechargeResult
|
||||
from pyfragment.utils import (
|
||||
execute_transaction_request,
|
||||
fragment_request,
|
||||
get_account_info,
|
||||
get_fragment_hash,
|
||||
make_headers,
|
||||
process_transaction,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE)
|
||||
|
||||
|
||||
async def _init_request(
|
||||
session: httpx.AsyncClient,
|
||||
fragment_hash: str,
|
||||
account: str,
|
||||
amount: int,
|
||||
) -> str:
|
||||
result = await fragment_request(
|
||||
session,
|
||||
fragment_hash,
|
||||
HEADERS,
|
||||
{
|
||||
"account": account,
|
||||
"amount": amount,
|
||||
"method": "initAdsRechargeRequest",
|
||||
},
|
||||
)
|
||||
req_id = result.get("req_id")
|
||||
if not req_id:
|
||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge"))
|
||||
return req_id
|
||||
|
||||
|
||||
async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> AdsRechargeResult:
|
||||
"""Add funds to your own Telegram Ads account.
|
||||
|
||||
Args:
|
||||
client: Authenticated :class:`FragmentClient` instance.
|
||||
account: Your Fragment Ads account identifier — the channel or bot username
|
||||
the Ads account is linked to (e.g. ``"@mychannel"``).
|
||||
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||
|
||||
Returns:
|
||||
:class:`AdsRechargeResult` with ``transaction_id`` and ``amount``.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If ``amount`` is not a valid integer in the allowed range.
|
||||
FragmentAPIError: If the Fragment API returns an error.
|
||||
UnexpectedError: For any other unexpected failure.
|
||||
"""
|
||||
if not isinstance(amount, int) or not (1 <= amount <= 1_000_000_000):
|
||||
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
|
||||
|
||||
try:
|
||||
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout)
|
||||
account_info = await get_account_info(client)
|
||||
|
||||
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||
await fragment_request(session, fragment_hash, HEADERS, {"method": "updateAdsState", "mode": "new"})
|
||||
req_id = await _init_request(session, fragment_hash, account, amount)
|
||||
|
||||
tx_data = {
|
||||
"account": json.dumps(account_info),
|
||||
"device": DEVICE,
|
||||
"transaction": 1,
|
||||
"id": req_id,
|
||||
"method": "getAdsRechargeLink",
|
||||
}
|
||||
transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash)
|
||||
|
||||
tx_hash = await process_transaction(client, transaction)
|
||||
return AdsRechargeResult(transaction_id=tx_hash, amount=amount)
|
||||
|
||||
except FragmentError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
|
||||
@@ -11,7 +11,7 @@ from pyfragment.types import (
|
||||
UnexpectedError,
|
||||
UserNotFoundError,
|
||||
)
|
||||
from pyfragment.types.constants import DEVICE, TON_PAGE
|
||||
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
|
||||
from pyfragment.utils import (
|
||||
execute_transaction_request,
|
||||
fragment_request,
|
||||
@@ -24,7 +24,7 @@ from pyfragment.utils import (
|
||||
if TYPE_CHECKING:
|
||||
from pyfragment.client import FragmentClient
|
||||
|
||||
HEADERS: dict[str, str] = make_headers(TON_PAGE)
|
||||
HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE)
|
||||
|
||||
|
||||
async def _search_recipient(
|
||||
@@ -92,7 +92,7 @@ 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, TON_PAGE, client.timeout)
|
||||
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout)
|
||||
account = await get_account_info(client)
|
||||
|
||||
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
|
||||
|
||||
@@ -15,6 +15,7 @@ from pyfragment.types.exceptions import (
|
||||
WalletError,
|
||||
)
|
||||
from pyfragment.types.results import (
|
||||
AdsRechargeResult,
|
||||
AdsTopupResult,
|
||||
LoginCodeResult,
|
||||
PremiumGiveawayResult,
|
||||
@@ -43,6 +44,7 @@ __all__ = [
|
||||
"VerificationError",
|
||||
"WalletError",
|
||||
# result types
|
||||
"AdsRechargeResult",
|
||||
"AdsTopupResult",
|
||||
"LoginCodeResult",
|
||||
"PremiumGiveawayResult",
|
||||
|
||||
@@ -25,7 +25,7 @@ STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy"
|
||||
STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway"
|
||||
PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift"
|
||||
PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway"
|
||||
TON_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
|
||||
ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
|
||||
NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers"
|
||||
|
||||
# Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
|
||||
|
||||
+24
-11
@@ -1,16 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = [
|
||||
"AdsTopupResult",
|
||||
"LoginCodeResult",
|
||||
"PremiumGiveawayResult",
|
||||
"PremiumResult",
|
||||
"StarsGiveawayResult",
|
||||
"StarsResult",
|
||||
"TerminateSessionsResult",
|
||||
"WalletInfo",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletInfo:
|
||||
@@ -105,6 +94,17 @@ class LoginCodeResult:
|
||||
return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdsRechargeResult:
|
||||
"""Result of a successful self-recharge of Telegram Ads balance."""
|
||||
|
||||
transaction_id: str
|
||||
amount: int
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"AdsRechargeResult(amount={self.amount} TON, tx='{self.transaction_id}')"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminateSessionsResult:
|
||||
"""Result of :meth:`FragmentClient.terminate_sessions`."""
|
||||
@@ -114,3 +114,16 @@ class TerminateSessionsResult:
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AdsRechargeResult",
|
||||
"AdsTopupResult",
|
||||
"LoginCodeResult",
|
||||
"PremiumGiveawayResult",
|
||||
"PremiumResult",
|
||||
"StarsGiveawayResult",
|
||||
"StarsResult",
|
||||
"TerminateSessionsResult",
|
||||
"WalletInfo",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Unit tests for recharge_ads — self-service Telegram Ads recharge."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pyfragment import FragmentClient
|
||||
from pyfragment.types import AdsRechargeResult, ConfigurationError
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_HASH, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
# recharge_ads validation tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_amount_zero(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_amount_too_high(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=1_000_000_001)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_float_amount(client: FragmentClient) -> None:
|
||||
with pytest.raises(ConfigurationError):
|
||||
await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=5.5) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# recharge_ads mocked tests
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recharge_ads_success(client: FragmentClient) -> None:
|
||||
with (
|
||||
patch("pyfragment.methods.recharge_ads.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.methods.recharge_ads.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)),
|
||||
patch(
|
||||
"pyfragment.methods.recharge_ads.fragment_request",
|
||||
AsyncMock(
|
||||
side_effect=[
|
||||
{}, # updateAdsState
|
||||
{"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest
|
||||
]
|
||||
),
|
||||
),
|
||||
patch("pyfragment.methods.recharge_ads.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)),
|
||||
patch("pyfragment.methods.recharge_ads.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)),
|
||||
):
|
||||
result = await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10)
|
||||
|
||||
assert isinstance(result, AdsRechargeResult)
|
||||
assert result.transaction_id == FAKE_TX_HASH
|
||||
assert result.amount == 10
|
||||
@@ -32,6 +32,13 @@ FAKE_RESPONSE: dict[str, Any] = {"status": "ok", "data": {"value": 42}}
|
||||
FAKE_ADDRESS: str = "UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5"
|
||||
FAKE_BALANCE_NANOTON: int = 1_500_000_000 # 1.5 TON
|
||||
|
||||
# recharge_ads
|
||||
FAKE_ADS_ACCOUNT: str = "@mychannel"
|
||||
|
||||
# Revenue withdrawals
|
||||
FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
FAKE_REVENUE_TX: str = "revenue_tx_abc123"
|
||||
|
||||
# Anonymous number
|
||||
FAKE_HTML_WITH_CODE: str = """
|
||||
<table>
|
||||
|
||||
Reference in New Issue
Block a user