adsun 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- adsun-1.0.0/.gitignore +9 -0
- adsun-1.0.0/LICENSE +21 -0
- adsun-1.0.0/PKG-INFO +118 -0
- adsun-1.0.0/README.md +85 -0
- adsun-1.0.0/pyproject.toml +46 -0
- adsun-1.0.0/src/adsun/__init__.py +19 -0
- adsun-1.0.0/src/adsun/client.py +99 -0
- adsun-1.0.0/src/adsun/errors.py +23 -0
- adsun-1.0.0/src/adsun/integrations/__init__.py +0 -0
- adsun-1.0.0/src/adsun/integrations/aiogram.py +21 -0
- adsun-1.0.0/src/adsun/integrations/ptb.py +20 -0
- adsun-1.0.0/src/adsun/models.py +37 -0
- adsun-1.0.0/src/adsun/py.typed +0 -0
- adsun-1.0.0/src/adsun/reward.py +54 -0
- adsun-1.0.0/tests/test_adsun.py +80 -0
adsun-1.0.0/.gitignore
ADDED
adsun-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adsun (https://adsun.io)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
adsun-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: adsun
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Adsun Python SDK: monetize Telegram bots with sponsored ads (aiogram, python-telegram-bot) and verify Telegram Mini App reward callbacks.
|
|
5
|
+
Project-URL: Homepage, https://adsun.io
|
|
6
|
+
Project-URL: Documentation, https://adsun.io/docs
|
|
7
|
+
Project-URL: Source, https://github.com/adsun-io/adsun-sdk
|
|
8
|
+
Project-URL: Issues, https://github.com/adsun-io/adsun-sdk/issues
|
|
9
|
+
Project-URL: Bot monetization, https://adsun.io/telegram-bot-monetization
|
|
10
|
+
Author-email: Adsun <support@adsun.io>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: ad-network,adsgram,adsgram-alternative,adsun,aiogram,bot-monetization,python-telegram-bot,rewarded-ads,telegram,telegram-ad-network,telegram-ads,telegram-bot,telegram-mini-app,telegram-monetization,ton,usdt
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Topic :: Communications :: Chat
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Provides-Extra: aiogram
|
|
26
|
+
Requires-Dist: aiogram>=3.0; extra == 'aiogram'
|
|
27
|
+
Requires-Dist: aiohttp>=3.8; extra == 'aiogram'
|
|
28
|
+
Provides-Extra: async
|
|
29
|
+
Requires-Dist: aiohttp>=3.8; extra == 'async'
|
|
30
|
+
Provides-Extra: ptb
|
|
31
|
+
Requires-Dist: python-telegram-bot>=20.0; extra == 'ptb'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# adsun — Telegram bot ads for Python
|
|
35
|
+
|
|
36
|
+
Official [Adsun](https://adsun.io) Python SDK. **Monetize your Telegram bot** with sponsored messages and earn **USDT**,
|
|
37
|
+
and verify **Telegram Mini App reward callbacks** on your server. Zero required dependencies; helpers for
|
|
38
|
+
**aiogram 3** and **python-telegram-bot 20+**.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install adsun # core (standard library only)
|
|
42
|
+
pip install "adsun[aiogram]" # + aiogram helper
|
|
43
|
+
pip install "adsun[ptb]" # + python-telegram-bot helper
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
1. Sign up at https://adsun.io/publishers, add your bot as a **Bot** platform and create a block.
|
|
47
|
+
2. Copy your **account token** (Profile → Token) and the numeric **block id** (e.g. `50001`).
|
|
48
|
+
|
|
49
|
+
## aiogram 3
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from aiogram.filters import Command
|
|
53
|
+
from adsun import AsyncAdsunBot
|
|
54
|
+
from adsun.integrations.aiogram import send_ad
|
|
55
|
+
|
|
56
|
+
ads = AsyncAdsunBot(token="YOUR_ACCOUNT_TOKEN", block_id="50001")
|
|
57
|
+
|
|
58
|
+
@dp.message(Command("bonus"))
|
|
59
|
+
async def bonus(message):
|
|
60
|
+
ad = await send_ad(message.bot, message.chat.id, ads, message.from_user.id, message.from_user.language_code or "en")
|
|
61
|
+
if ad is None:
|
|
62
|
+
await message.answer("No offer right now")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## python-telegram-bot
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from adsun import AsyncAdsunBot
|
|
69
|
+
from adsun.integrations.ptb import send_ad
|
|
70
|
+
|
|
71
|
+
ads = AsyncAdsunBot(token="YOUR_ACCOUNT_TOKEN", block_id="50001")
|
|
72
|
+
|
|
73
|
+
async def bonus(update, context):
|
|
74
|
+
u = update.effective_user
|
|
75
|
+
await send_ad(context.bot, update.effective_chat.id, ads, u.id, u.language_code or "en")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Any framework (sync, no dependencies)
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from adsun import AdsunBot
|
|
82
|
+
|
|
83
|
+
ads = AdsunBot(token="YOUR_ACCOUNT_TOKEN", block_id="50001")
|
|
84
|
+
ad = ads.get_ad(user_id=123456789, language="en") # None = no ad right now
|
|
85
|
+
if ad:
|
|
86
|
+
print(ad.text_html, ad.image_url, ad.buttons()) # [[(text, url)], [(reward_text, reward_url)]]
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Send it yourself with `parse_mode="HTML"` and `protect_content=True` (ad messages must not be forwardable).
|
|
90
|
+
|
|
91
|
+
Errors: `InvalidToken` (401), `BlockNotActive` (404), `BadRequest` (400), `AdsunError` (others / network).
|
|
92
|
+
|
|
93
|
+
## Reward URL callback (Mini Apps and bots)
|
|
94
|
+
|
|
95
|
+
Set the block's Reward URL in the panel:
|
|
96
|
+
`https://your-app.com/adsun/reward?userid=[userId]&block=[blockId]&secret=LONG_RANDOM_SECRET`
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from adsun import parse_reward_callback
|
|
100
|
+
|
|
101
|
+
@app.get("/adsun/reward") # Flask / FastAPI / Django — any dict-like query works
|
|
102
|
+
def reward():
|
|
103
|
+
cb = parse_reward_callback(request.args, secret=REWARD_SECRET)
|
|
104
|
+
if cb is None:
|
|
105
|
+
return "forbidden", 403
|
|
106
|
+
add_coins(cb.user_id) # make this idempotent: Adsun retries on 5xx / network errors
|
|
107
|
+
return "ok"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## HTTP API (what the SDK calls)
|
|
111
|
+
|
|
112
|
+
`GET https://api.adsun.io/advbot?tgid=<user id>&blockid=<number>&language=<code>&token=<account token>`
|
|
113
|
+
→ `200 {text_html, click_url, button_name, image_url, button_reward_name, reward_url}` or `204` (no ad).
|
|
114
|
+
|
|
115
|
+
Links: https://adsun.io/docs · https://adsun.io/telegram-bot-monetization · support@adsun.io ·
|
|
116
|
+
[@ADSunSupport](https://t.me/ADSunSupport)
|
|
117
|
+
|
|
118
|
+
MIT License. Adsun is not affiliated with Telegram.
|
adsun-1.0.0/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# adsun — Telegram bot ads for Python
|
|
2
|
+
|
|
3
|
+
Official [Adsun](https://adsun.io) Python SDK. **Monetize your Telegram bot** with sponsored messages and earn **USDT**,
|
|
4
|
+
and verify **Telegram Mini App reward callbacks** on your server. Zero required dependencies; helpers for
|
|
5
|
+
**aiogram 3** and **python-telegram-bot 20+**.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install adsun # core (standard library only)
|
|
9
|
+
pip install "adsun[aiogram]" # + aiogram helper
|
|
10
|
+
pip install "adsun[ptb]" # + python-telegram-bot helper
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
1. Sign up at https://adsun.io/publishers, add your bot as a **Bot** platform and create a block.
|
|
14
|
+
2. Copy your **account token** (Profile → Token) and the numeric **block id** (e.g. `50001`).
|
|
15
|
+
|
|
16
|
+
## aiogram 3
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from aiogram.filters import Command
|
|
20
|
+
from adsun import AsyncAdsunBot
|
|
21
|
+
from adsun.integrations.aiogram import send_ad
|
|
22
|
+
|
|
23
|
+
ads = AsyncAdsunBot(token="YOUR_ACCOUNT_TOKEN", block_id="50001")
|
|
24
|
+
|
|
25
|
+
@dp.message(Command("bonus"))
|
|
26
|
+
async def bonus(message):
|
|
27
|
+
ad = await send_ad(message.bot, message.chat.id, ads, message.from_user.id, message.from_user.language_code or "en")
|
|
28
|
+
if ad is None:
|
|
29
|
+
await message.answer("No offer right now")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## python-telegram-bot
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from adsun import AsyncAdsunBot
|
|
36
|
+
from adsun.integrations.ptb import send_ad
|
|
37
|
+
|
|
38
|
+
ads = AsyncAdsunBot(token="YOUR_ACCOUNT_TOKEN", block_id="50001")
|
|
39
|
+
|
|
40
|
+
async def bonus(update, context):
|
|
41
|
+
u = update.effective_user
|
|
42
|
+
await send_ad(context.bot, update.effective_chat.id, ads, u.id, u.language_code or "en")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Any framework (sync, no dependencies)
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from adsun import AdsunBot
|
|
49
|
+
|
|
50
|
+
ads = AdsunBot(token="YOUR_ACCOUNT_TOKEN", block_id="50001")
|
|
51
|
+
ad = ads.get_ad(user_id=123456789, language="en") # None = no ad right now
|
|
52
|
+
if ad:
|
|
53
|
+
print(ad.text_html, ad.image_url, ad.buttons()) # [[(text, url)], [(reward_text, reward_url)]]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Send it yourself with `parse_mode="HTML"` and `protect_content=True` (ad messages must not be forwardable).
|
|
57
|
+
|
|
58
|
+
Errors: `InvalidToken` (401), `BlockNotActive` (404), `BadRequest` (400), `AdsunError` (others / network).
|
|
59
|
+
|
|
60
|
+
## Reward URL callback (Mini Apps and bots)
|
|
61
|
+
|
|
62
|
+
Set the block's Reward URL in the panel:
|
|
63
|
+
`https://your-app.com/adsun/reward?userid=[userId]&block=[blockId]&secret=LONG_RANDOM_SECRET`
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from adsun import parse_reward_callback
|
|
67
|
+
|
|
68
|
+
@app.get("/adsun/reward") # Flask / FastAPI / Django — any dict-like query works
|
|
69
|
+
def reward():
|
|
70
|
+
cb = parse_reward_callback(request.args, secret=REWARD_SECRET)
|
|
71
|
+
if cb is None:
|
|
72
|
+
return "forbidden", 403
|
|
73
|
+
add_coins(cb.user_id) # make this idempotent: Adsun retries on 5xx / network errors
|
|
74
|
+
return "ok"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## HTTP API (what the SDK calls)
|
|
78
|
+
|
|
79
|
+
`GET https://api.adsun.io/advbot?tgid=<user id>&blockid=<number>&language=<code>&token=<account token>`
|
|
80
|
+
→ `200 {text_html, click_url, button_name, image_url, button_reward_name, reward_url}` or `204` (no ad).
|
|
81
|
+
|
|
82
|
+
Links: https://adsun.io/docs · https://adsun.io/telegram-bot-monetization · support@adsun.io ·
|
|
83
|
+
[@ADSunSupport](https://t.me/ADSunSupport)
|
|
84
|
+
|
|
85
|
+
MIT License. Adsun is not affiliated with Telegram.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "adsun"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Adsun Python SDK: monetize Telegram bots with sponsored ads (aiogram, python-telegram-bot) and verify Telegram Mini App reward callbacks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
authors = [{ name = "Adsun", email = "support@adsun.io" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"adsun", "telegram", "telegram-bot", "telegram-ads", "telegram-ad-network", "bot-monetization",
|
|
16
|
+
"telegram-monetization", "aiogram", "python-telegram-bot", "telegram-mini-app", "rewarded-ads",
|
|
17
|
+
"ad-network", "adsgram", "adsgram-alternative", "usdt", "ton",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 5 - Production/Stable",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Framework :: AsyncIO",
|
|
26
|
+
"Topic :: Communications :: Chat",
|
|
27
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
28
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
dependencies = []
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
aiogram = ["aiogram>=3.0", "aiohttp>=3.8"]
|
|
35
|
+
ptb = ["python-telegram-bot>=20.0"]
|
|
36
|
+
async = ["aiohttp>=3.8"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://adsun.io"
|
|
40
|
+
Documentation = "https://adsun.io/docs"
|
|
41
|
+
Source = "https://github.com/adsun-io/adsun-sdk"
|
|
42
|
+
Issues = "https://github.com/adsun-io/adsun-sdk/issues"
|
|
43
|
+
"Bot monetization" = "https://adsun.io/telegram-bot-monetization"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/adsun"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Adsun — Telegram ad network SDK for Python.
|
|
2
|
+
|
|
3
|
+
Docs: https://adsun.io/docs · Bot monetization: https://adsun.io/telegram-bot-monetization
|
|
4
|
+
|
|
5
|
+
from adsun import AdsunBot
|
|
6
|
+
ads = AdsunBot(token="YOUR_ACCOUNT_TOKEN", block_id="50001")
|
|
7
|
+
ad = ads.get_ad(user_id=123456789, language="en") # None when no ad right now
|
|
8
|
+
"""
|
|
9
|
+
from .client import ADSUN_API_URL, AdsunBot, AsyncAdsunBot
|
|
10
|
+
from .errors import AdsunError, BadRequest, BlockNotActive, InvalidToken
|
|
11
|
+
from .models import BotAd
|
|
12
|
+
from .reward import RewardCallback, parse_reward_callback, verify_secret
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.0"
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ADSUN_API_URL", "AdsunBot", "AsyncAdsunBot", "BotAd",
|
|
17
|
+
"AdsunError", "BadRequest", "BlockNotActive", "InvalidToken",
|
|
18
|
+
"RewardCallback", "parse_reward_callback", "verify_secret",
|
|
19
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""HTTP clients for the Adsun bot ads API (``GET /advbot``)."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict, Optional, Union
|
|
5
|
+
from urllib.error import HTTPError, URLError
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
from urllib.request import Request, urlopen
|
|
8
|
+
|
|
9
|
+
from .errors import AdsunError, BadRequest, InvalidToken, error_for
|
|
10
|
+
from .models import BotAd
|
|
11
|
+
|
|
12
|
+
ADSUN_API_URL = "https://api.adsun.io"
|
|
13
|
+
_UA = "adsun-python/1.0.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def block_number(block_id: Union[str, int]) -> str:
|
|
17
|
+
"""``"bot-50001"`` / ``"tw-50001"`` / ``50001`` → ``"50001"`` (the API wants the numeric part)."""
|
|
18
|
+
num = re.sub(r"^(?:bot-|tw-)", "", str(block_id).strip(), flags=re.I)
|
|
19
|
+
if not num.isdigit():
|
|
20
|
+
raise BadRequest("block_id must be numeric, e.g. 50001 (got {!r})".format(block_id), 400)
|
|
21
|
+
return num
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Base:
|
|
25
|
+
def __init__(self, token: str, block_id: Union[str, int], api_url: str = ADSUN_API_URL, timeout: float = 10.0):
|
|
26
|
+
if not token:
|
|
27
|
+
raise InvalidToken("token is required (Adsun panel → Profile → Token)", 401)
|
|
28
|
+
self.token = token
|
|
29
|
+
self.block_id = block_number(block_id)
|
|
30
|
+
self.api_url = api_url.rstrip("/")
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
|
|
33
|
+
def _params(self, user_id: Union[int, str], language: str) -> Dict[str, str]:
|
|
34
|
+
uid = str(user_id)
|
|
35
|
+
if not uid.isdigit():
|
|
36
|
+
raise BadRequest("user_id must be the numeric Telegram user id", 400)
|
|
37
|
+
return {"tgid": uid, "blockid": self.block_id, "language": language or "", "token": self.token}
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _message(body: bytes) -> str:
|
|
41
|
+
try:
|
|
42
|
+
return str(json.loads(body.decode("utf-8")).get("error") or "")
|
|
43
|
+
except Exception:
|
|
44
|
+
return body.decode("utf-8", "replace")[:200]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AdsunBot(_Base):
|
|
48
|
+
"""Synchronous client (standard library only).
|
|
49
|
+
|
|
50
|
+
>>> ads = AdsunBot(token="...", block_id="50001")
|
|
51
|
+
>>> ad = ads.get_ad(user_id=123456789, language="en")
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def get_ad(self, user_id: Union[int, str], language: str = "") -> Optional[BotAd]:
|
|
55
|
+
"""Returns a :class:`BotAd`, or ``None`` when there is no ad right now (HTTP 204)."""
|
|
56
|
+
url = "{}/advbot?{}".format(self.api_url, urlencode(self._params(user_id, language)))
|
|
57
|
+
req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
|
|
58
|
+
try:
|
|
59
|
+
with urlopen(req, timeout=self.timeout) as r: # noqa: S310 (fixed https API)
|
|
60
|
+
if r.status == 204:
|
|
61
|
+
return None
|
|
62
|
+
body = r.read()
|
|
63
|
+
if not body:
|
|
64
|
+
return None
|
|
65
|
+
return BotAd.from_dict(json.loads(body.decode("utf-8")))
|
|
66
|
+
except HTTPError as e:
|
|
67
|
+
raise error_for(e.code, self._message(e.read() or b""))
|
|
68
|
+
except URLError as e:
|
|
69
|
+
raise AdsunError("network error: {}".format(e.reason), 0)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AsyncAdsunBot(_Base):
|
|
73
|
+
"""asyncio client (needs ``pip install adsun[async]``). Pass your own ``aiohttp.ClientSession`` to reuse it."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, token: str, block_id: Union[str, int], api_url: str = ADSUN_API_URL, timeout: float = 10.0, session: Any = None):
|
|
76
|
+
super().__init__(token, block_id, api_url, timeout)
|
|
77
|
+
self._session = session
|
|
78
|
+
|
|
79
|
+
async def get_ad(self, user_id: Union[int, str], language: str = "") -> Optional[BotAd]:
|
|
80
|
+
try:
|
|
81
|
+
import aiohttp
|
|
82
|
+
except ImportError as e: # pragma: no cover
|
|
83
|
+
raise AdsunError("AsyncAdsunBot needs aiohttp: pip install 'adsun[async]'") from e
|
|
84
|
+
params = self._params(user_id, language)
|
|
85
|
+
own = self._session is None
|
|
86
|
+
session = self._session or aiohttp.ClientSession(headers={"User-Agent": _UA})
|
|
87
|
+
try:
|
|
88
|
+
async with session.get(self.api_url + "/advbot", params=params, timeout=aiohttp.ClientTimeout(total=self.timeout)) as r:
|
|
89
|
+
if r.status == 204:
|
|
90
|
+
return None
|
|
91
|
+
body = await r.read()
|
|
92
|
+
if r.status >= 400:
|
|
93
|
+
raise error_for(r.status, self._message(body))
|
|
94
|
+
return BotAd.from_dict(json.loads(body.decode("utf-8"))) if body else None
|
|
95
|
+
except aiohttp.ClientError as e:
|
|
96
|
+
raise AdsunError("network error: {}".format(e), 0)
|
|
97
|
+
finally:
|
|
98
|
+
if own:
|
|
99
|
+
await session.close()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class AdsunError(Exception):
|
|
2
|
+
"""Base error. ``status`` is the HTTP status from the Adsun API (0 for network errors)."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, message: str, status: int = 0):
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
self.status = status
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BadRequest(AdsunError):
|
|
10
|
+
"""400 — e.g. blockid must be the numeric part only (``50001``, not ``bot-50001``)."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InvalidToken(AdsunError):
|
|
14
|
+
"""401 — account token is wrong or was regenerated (Adsun panel → Profile → Token)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BlockNotActive(AdsunError):
|
|
18
|
+
"""404 — the block does not exist, is not active or belongs to another account."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def error_for(status: int, message: str) -> AdsunError:
|
|
22
|
+
cls = {400: BadRequest, 401: InvalidToken, 404: BlockNotActive}.get(status, AdsunError)
|
|
23
|
+
return cls(message or "Adsun API error {}".format(status), status)
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""aiogram 3 helper: ``await send_ad(bot, chat_id, ads, user_id, language)``."""
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from ..client import AsyncAdsunBot
|
|
5
|
+
from ..models import BotAd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def send_ad(bot, chat_id: int, ads: AsyncAdsunBot, user_id: int, language: str = "") -> Optional[BotAd]:
|
|
9
|
+
"""Fetches an Adsun ad and sends it. Returns the ad, or ``None`` when there was no ad."""
|
|
10
|
+
from aiogram.enums import ParseMode
|
|
11
|
+
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
12
|
+
|
|
13
|
+
ad = await ads.get_ad(user_id=user_id, language=language)
|
|
14
|
+
if ad is None:
|
|
15
|
+
return None
|
|
16
|
+
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=t, url=u) for t, u in row] for row in ad.buttons()])
|
|
17
|
+
if ad.image_url:
|
|
18
|
+
await bot.send_photo(chat_id, photo=ad.image_url, caption=ad.text_html, parse_mode=ParseMode.HTML, reply_markup=kb, protect_content=True)
|
|
19
|
+
else:
|
|
20
|
+
await bot.send_message(chat_id, ad.text_html, parse_mode=ParseMode.HTML, reply_markup=kb, protect_content=True)
|
|
21
|
+
return ad
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""python-telegram-bot (v20+) helper: ``await send_ad(context.bot, chat_id, ads, user_id, language)``."""
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from ..client import AsyncAdsunBot
|
|
5
|
+
from ..models import BotAd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def send_ad(bot, chat_id: int, ads: AsyncAdsunBot, user_id: int, language: str = "") -> Optional[BotAd]:
|
|
9
|
+
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
|
10
|
+
from telegram.constants import ParseMode
|
|
11
|
+
|
|
12
|
+
ad = await ads.get_ad(user_id=user_id, language=language)
|
|
13
|
+
if ad is None:
|
|
14
|
+
return None
|
|
15
|
+
kb = InlineKeyboardMarkup([[InlineKeyboardButton(t, url=u) for t, u in row] for row in ad.buttons()])
|
|
16
|
+
if ad.image_url:
|
|
17
|
+
await bot.send_photo(chat_id, photo=ad.image_url, caption=ad.text_html, parse_mode=ParseMode.HTML, reply_markup=kb, protect_content=True)
|
|
18
|
+
else:
|
|
19
|
+
await bot.send_message(chat_id, ad.text_html, parse_mode=ParseMode.HTML, reply_markup=kb, protect_content=True)
|
|
20
|
+
return ad
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class BotAd:
|
|
7
|
+
"""Sponsored bot message returned by ``GET /advbot``.
|
|
8
|
+
|
|
9
|
+
Send it with ``parse_mode="HTML"`` and ``protect_content=True`` (ads must not be forwardable).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
text_html: str
|
|
13
|
+
click_url: str
|
|
14
|
+
button_name: str
|
|
15
|
+
image_url: Optional[str] = None
|
|
16
|
+
button_reward_name: Optional[str] = None
|
|
17
|
+
reward_url: Optional[str] = None
|
|
18
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_dict(cls, data: Dict[str, Any]) -> "BotAd":
|
|
22
|
+
return cls(
|
|
23
|
+
text_html=str(data.get("text_html") or ""),
|
|
24
|
+
click_url=str(data.get("click_url") or ""),
|
|
25
|
+
button_name=str(data.get("button_name") or "Open"),
|
|
26
|
+
image_url=data.get("image_url") or None,
|
|
27
|
+
button_reward_name=data.get("button_reward_name") or None,
|
|
28
|
+
reward_url=data.get("reward_url") or None,
|
|
29
|
+
raw=dict(data),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def buttons(self):
|
|
33
|
+
"""Inline keyboard rows as ``[(text, url), ...]`` — framework independent."""
|
|
34
|
+
rows = [[(self.button_name, self.click_url)]]
|
|
35
|
+
if self.button_reward_name and self.reward_url:
|
|
36
|
+
rows.append([(self.button_reward_name, self.reward_url)])
|
|
37
|
+
return rows
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Server-side helpers for the Adsun Reward URL callback.
|
|
2
|
+
|
|
3
|
+
In the Adsun panel set the block's Reward URL, for example::
|
|
4
|
+
|
|
5
|
+
https://your-app.com/adsun/reward?userid=[userId]&block=[blockId]&secret=YOUR_LONG_RANDOM_SECRET
|
|
6
|
+
|
|
7
|
+
Adsun calls it (HTTP GET) only after a server-verified full watch (Reward) or task completion (Task).
|
|
8
|
+
``[userId]`` / ``[blockId]`` are replaced; ``user`` and ``block`` params are added too.
|
|
9
|
+
Failed calls (5xx / network) are retried, so handle them idempotently. Never grant rewards from the
|
|
10
|
+
client-side ``.then()`` alone — grant them here.
|
|
11
|
+
"""
|
|
12
|
+
import hmac
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Mapping, Optional, Sequence, Union
|
|
15
|
+
|
|
16
|
+
QueryValue = Union[str, Sequence[str]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _first(q: Mapping[str, QueryValue], *names: str) -> str:
|
|
20
|
+
for n in names:
|
|
21
|
+
v = q.get(n)
|
|
22
|
+
if v is None:
|
|
23
|
+
continue
|
|
24
|
+
if not isinstance(v, str):
|
|
25
|
+
v = v[0] if len(v) else ""
|
|
26
|
+
if v:
|
|
27
|
+
return str(v)
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def verify_secret(received: Optional[str], expected: str) -> bool:
|
|
32
|
+
"""Constant-time comparison of the ``secret`` you put in your Reward URL."""
|
|
33
|
+
if not expected or not received:
|
|
34
|
+
return False
|
|
35
|
+
return hmac.compare_digest(str(received).encode(), str(expected).encode())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class RewardCallback:
|
|
40
|
+
user_id: str
|
|
41
|
+
block_id: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_reward_callback(query: Mapping[str, QueryValue], secret: Optional[str] = None, secret_param: str = "secret") -> Optional[RewardCallback]:
|
|
45
|
+
"""Parse a Reward URL request. Works with dicts from Flask, FastAPI, Django or ``urllib.parse.parse_qs``.
|
|
46
|
+
|
|
47
|
+
Returns ``None`` when the user id is missing or (if ``secret`` is given) the secret does not match.
|
|
48
|
+
"""
|
|
49
|
+
if secret is not None and not verify_secret(_first(query, secret_param), secret):
|
|
50
|
+
return None
|
|
51
|
+
user = _first(query, "userid", "userId", "user", "user_id")
|
|
52
|
+
if not user.isdigit():
|
|
53
|
+
return None
|
|
54
|
+
return RewardCallback(user_id=user, block_id=_first(query, "block", "blockId", "blockid", "block_id"))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest import mock
|
|
5
|
+
from urllib.error import HTTPError
|
|
6
|
+
from urllib.parse import parse_qs, urlparse
|
|
7
|
+
|
|
8
|
+
from adsun import AdsunBot, BadRequest, BlockNotActive, InvalidToken, parse_reward_callback, verify_secret
|
|
9
|
+
from adsun.client import block_number
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Resp(io.BytesIO):
|
|
13
|
+
def __init__(self, status, body=b""):
|
|
14
|
+
super().__init__(body)
|
|
15
|
+
self.status = status
|
|
16
|
+
|
|
17
|
+
def __enter__(self):
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
def __exit__(self, *a):
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
AD = {"text_html": "<a href='x'>Hi</a>", "click_url": "https://api.adsun.io/short/a", "button_name": "Open",
|
|
25
|
+
"image_url": "", "button_reward_name": "Claim reward!", "reward_url": "https://api.adsun.io/short/b"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ClientTests(unittest.TestCase):
|
|
29
|
+
def test_block_number_strips_prefix(self):
|
|
30
|
+
self.assertEqual(block_number("bot-50001"), "50001")
|
|
31
|
+
self.assertEqual(block_number("tw-50001"), "50001")
|
|
32
|
+
self.assertEqual(block_number(50001), "50001")
|
|
33
|
+
with self.assertRaises(BadRequest):
|
|
34
|
+
block_number("abc")
|
|
35
|
+
|
|
36
|
+
def test_get_ad_builds_request_and_parses(self):
|
|
37
|
+
seen = {}
|
|
38
|
+
|
|
39
|
+
def fake(req, timeout):
|
|
40
|
+
seen["url"] = req.full_url
|
|
41
|
+
return Resp(200, json.dumps(AD).encode())
|
|
42
|
+
|
|
43
|
+
with mock.patch("adsun.client.urlopen", fake):
|
|
44
|
+
ad = AdsunBot(token="t" * 20, block_id="bot-50001").get_ad(123, "en")
|
|
45
|
+
u = urlparse(seen["url"])
|
|
46
|
+
self.assertEqual(u.netloc + u.path, "api.adsun.io/advbot")
|
|
47
|
+
self.assertEqual(parse_qs(u.query), {"tgid": ["123"], "blockid": ["50001"], "language": ["en"], "token": ["t" * 20]})
|
|
48
|
+
self.assertEqual(ad.button_name, "Open")
|
|
49
|
+
self.assertIsNone(ad.image_url)
|
|
50
|
+
self.assertEqual(len(ad.buttons()), 2)
|
|
51
|
+
|
|
52
|
+
def test_no_ad_returns_none(self):
|
|
53
|
+
with mock.patch("adsun.client.urlopen", lambda r, timeout: Resp(204)):
|
|
54
|
+
self.assertIsNone(AdsunBot(token="t" * 20, block_id=50001).get_ad(1))
|
|
55
|
+
|
|
56
|
+
def test_errors_map_to_exceptions(self):
|
|
57
|
+
for status, exc in ((401, InvalidToken), (404, BlockNotActive), (400, BadRequest)):
|
|
58
|
+
def fake(req, timeout, s=status):
|
|
59
|
+
raise HTTPError(req.full_url, s, "x", {}, io.BytesIO(b'{"error":"nope"}'))
|
|
60
|
+
with mock.patch("adsun.client.urlopen", fake):
|
|
61
|
+
with self.assertRaises(exc) as cm:
|
|
62
|
+
AdsunBot(token="t" * 20, block_id=50001).get_ad(1)
|
|
63
|
+
self.assertEqual(cm.exception.status, status)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RewardTests(unittest.TestCase):
|
|
67
|
+
def test_secret_required_when_given(self):
|
|
68
|
+
q = {"userid": "777", "block": "50001", "secret": "s3cret"}
|
|
69
|
+
self.assertEqual(parse_reward_callback(q, secret="s3cret").user_id, "777")
|
|
70
|
+
self.assertIsNone(parse_reward_callback(q, secret="other"))
|
|
71
|
+
self.assertIsNone(parse_reward_callback({"userid": "777"}, secret="s3cret"))
|
|
72
|
+
|
|
73
|
+
def test_parse_qs_lists_and_bad_user(self):
|
|
74
|
+
self.assertEqual(parse_reward_callback({"user": ["42"], "block": ["50001"]}).block_id, "50001")
|
|
75
|
+
self.assertIsNone(parse_reward_callback({"userid": "abc"}))
|
|
76
|
+
self.assertFalse(verify_secret("", ""))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
unittest.main()
|