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:
bohd4nx
2026-03-25 00:57:31 +02:00
parent 327ca35190
commit dfe7bb4140
13 changed files with 256 additions and 18 deletions
+6
View File
@@ -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 (124 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 (11 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
View File
@@ -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:
+40
View File
@@ -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 # 11 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())
+2
View File
@@ -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",
+15
View File
@@ -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.
+2
View File
@@ -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",
+94
View File
@@ -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
+3 -3
View File
@@ -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:
+2
View File
@@ -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",
+1 -1
View File
@@ -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
View File
@@ -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",
]
+56
View File
@@ -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
+7
View File
@@ -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>