nativeads 0.1.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.
- nativeads-0.1.0/.gitignore +6 -0
- nativeads-0.1.0/PKG-INFO +88 -0
- nativeads-0.1.0/README.md +73 -0
- nativeads-0.1.0/examples/README.md +23 -0
- nativeads-0.1.0/examples/chatgpt_aiogram_bot.py +109 -0
- nativeads-0.1.0/pyproject.toml +23 -0
- nativeads-0.1.0/src/nativeads/__init__.py +5 -0
- nativeads-0.1.0/src/nativeads/client.py +223 -0
- nativeads-0.1.0/src/nativeads/keyboard.py +77 -0
- nativeads-0.1.0/src/nativeads/middleware.py +68 -0
- nativeads-0.1.0/tests/run.py +147 -0
- nativeads-0.1.0/tests/unit.py +336 -0
nativeads-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nativeads
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: NativeAds SDK for Telegram bots — inject ads into LLM replies (aiogram / python-telegram-bot / raw).
|
|
5
|
+
Project-URL: Homepage, https://nativeads.cloud
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: ads,aiogram,bot,monetization,python-telegram-bot,telegram
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Requires-Dist: httpx>=0.25.0
|
|
10
|
+
Provides-Extra: aiogram
|
|
11
|
+
Requires-Dist: aiogram>=3.0; extra == 'aiogram'
|
|
12
|
+
Provides-Extra: ptb
|
|
13
|
+
Requires-Dist: python-telegram-bot>=20.0; extra == 'ptb'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# nativeads (Python)
|
|
17
|
+
|
|
18
|
+
Python SDK for [NativeAds](https://nativeads.cloud) — monetize your Telegram bot by
|
|
19
|
+
injecting ads into LLM replies. Works with **aiogram 3.x**, **python-telegram-bot v20+**,
|
|
20
|
+
or raw bot code.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install nativeads # core (httpx only)
|
|
24
|
+
pip install nativeads[aiogram] # + aiogram 3.x
|
|
25
|
+
pip install nativeads[ptb] # + python-telegram-bot v20+
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick start (`inject`)
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from nativeads import NativeAds
|
|
32
|
+
|
|
33
|
+
ads = NativeAds(api_key="sk_live_xxx", platform_id="plt_xxx")
|
|
34
|
+
|
|
35
|
+
# inside an async message handler, after generating the LLM answer:
|
|
36
|
+
result = await ads.inject(
|
|
37
|
+
user_id=message.from_user.id,
|
|
38
|
+
message=llm_answer,
|
|
39
|
+
language_code=message.from_user.language_code,
|
|
40
|
+
is_premium=message.from_user.is_premium,
|
|
41
|
+
keyboard=my_keyboard, # optional — aiogram / PTB / raw dict
|
|
42
|
+
# ad_button_position="top", # default "bottom"
|
|
43
|
+
)
|
|
44
|
+
await message.answer(result.message, reply_markup=result.keyboard)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### aiogram middleware (DI)
|
|
48
|
+
```python
|
|
49
|
+
from nativeads import NativeAds
|
|
50
|
+
from nativeads.middleware import NativeAdsMiddleware
|
|
51
|
+
|
|
52
|
+
mw = NativeAdsMiddleware(api_key="sk_live_xxx", platform_id="plt_xxx")
|
|
53
|
+
dp.message.middleware(mw)
|
|
54
|
+
dp.shutdown.register(mw.aclose)
|
|
55
|
+
|
|
56
|
+
async def handler(message: Message, nativeads: NativeAds):
|
|
57
|
+
result = await nativeads.inject(user_id=message.from_user.id, message=answer)
|
|
58
|
+
await message.answer(result.message, reply_markup=result.keyboard)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Privacy-friendly mode (`fetch`)
|
|
62
|
+
```python
|
|
63
|
+
res = await ads.fetch(user_id=uid, language_code="ru")
|
|
64
|
+
if res.has_ad:
|
|
65
|
+
text = f"{llm_answer}\n\n{res.ad.ad_text}"
|
|
66
|
+
# render res.ad.button_text / res.ad.button_url yourself
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Guarantees
|
|
70
|
+
- **Never raises.** On any error (timeout, network, 5xx) `inject()`/`fetch()` return your
|
|
71
|
+
original message and keyboard unchanged. Default timeout 3s.
|
|
72
|
+
- **Your buttons are preserved** — the ad button is a separate row (top/bottom), respecting
|
|
73
|
+
Telegram's 4096-char and 13-row limits.
|
|
74
|
+
- **Click tracking is automatic** — `button_url` is a tracking redirect.
|
|
75
|
+
|
|
76
|
+
## API
|
|
77
|
+
- `NativeAds(api_key, platform_id=None, *, base_url=..., timeout=3.0, platform="telegram", show_every=1, skip_first=0)`
|
|
78
|
+
- `show_every` — ad on every Nth message (5 = optimal, 0 = off); N>1 keeps the first message ad-free.
|
|
79
|
+
- `skip_first` — never show an ad on a user's first N messages.
|
|
80
|
+
- Frequency is client-side (in-memory per-user counter); skipped turns return your message without a server call.
|
|
81
|
+
- `NativeAdsMiddleware(...)` accepts the same `show_every` / `skip_first`.
|
|
82
|
+
- `await ads.inject(*, user_id, message, language_code=None, is_premium=None, keyboard=None, ad_button_position="bottom", parse_mode=None) -> InjectResult`
|
|
83
|
+
- `await ads.fetch(*, user_id, language_code=None, is_premium=None, parse_mode=None) -> FetchResult`
|
|
84
|
+
- `await ads.aclose()`
|
|
85
|
+
|
|
86
|
+
`InjectResult(message, has_ad, impression_id, ad, keyboard)` ·
|
|
87
|
+
`FetchResult(has_ad, impression_id, ad)` ·
|
|
88
|
+
`Ad(ad_text, button_text, button_url, ad_text_formatted)`
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# nativeads (Python)
|
|
2
|
+
|
|
3
|
+
Python SDK for [NativeAds](https://nativeads.cloud) — monetize your Telegram bot by
|
|
4
|
+
injecting ads into LLM replies. Works with **aiogram 3.x**, **python-telegram-bot v20+**,
|
|
5
|
+
or raw bot code.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install nativeads # core (httpx only)
|
|
9
|
+
pip install nativeads[aiogram] # + aiogram 3.x
|
|
10
|
+
pip install nativeads[ptb] # + python-telegram-bot v20+
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start (`inject`)
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from nativeads import NativeAds
|
|
17
|
+
|
|
18
|
+
ads = NativeAds(api_key="sk_live_xxx", platform_id="plt_xxx")
|
|
19
|
+
|
|
20
|
+
# inside an async message handler, after generating the LLM answer:
|
|
21
|
+
result = await ads.inject(
|
|
22
|
+
user_id=message.from_user.id,
|
|
23
|
+
message=llm_answer,
|
|
24
|
+
language_code=message.from_user.language_code,
|
|
25
|
+
is_premium=message.from_user.is_premium,
|
|
26
|
+
keyboard=my_keyboard, # optional — aiogram / PTB / raw dict
|
|
27
|
+
# ad_button_position="top", # default "bottom"
|
|
28
|
+
)
|
|
29
|
+
await message.answer(result.message, reply_markup=result.keyboard)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### aiogram middleware (DI)
|
|
33
|
+
```python
|
|
34
|
+
from nativeads import NativeAds
|
|
35
|
+
from nativeads.middleware import NativeAdsMiddleware
|
|
36
|
+
|
|
37
|
+
mw = NativeAdsMiddleware(api_key="sk_live_xxx", platform_id="plt_xxx")
|
|
38
|
+
dp.message.middleware(mw)
|
|
39
|
+
dp.shutdown.register(mw.aclose)
|
|
40
|
+
|
|
41
|
+
async def handler(message: Message, nativeads: NativeAds):
|
|
42
|
+
result = await nativeads.inject(user_id=message.from_user.id, message=answer)
|
|
43
|
+
await message.answer(result.message, reply_markup=result.keyboard)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Privacy-friendly mode (`fetch`)
|
|
47
|
+
```python
|
|
48
|
+
res = await ads.fetch(user_id=uid, language_code="ru")
|
|
49
|
+
if res.has_ad:
|
|
50
|
+
text = f"{llm_answer}\n\n{res.ad.ad_text}"
|
|
51
|
+
# render res.ad.button_text / res.ad.button_url yourself
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Guarantees
|
|
55
|
+
- **Never raises.** On any error (timeout, network, 5xx) `inject()`/`fetch()` return your
|
|
56
|
+
original message and keyboard unchanged. Default timeout 3s.
|
|
57
|
+
- **Your buttons are preserved** — the ad button is a separate row (top/bottom), respecting
|
|
58
|
+
Telegram's 4096-char and 13-row limits.
|
|
59
|
+
- **Click tracking is automatic** — `button_url` is a tracking redirect.
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
- `NativeAds(api_key, platform_id=None, *, base_url=..., timeout=3.0, platform="telegram", show_every=1, skip_first=0)`
|
|
63
|
+
- `show_every` — ad on every Nth message (5 = optimal, 0 = off); N>1 keeps the first message ad-free.
|
|
64
|
+
- `skip_first` — never show an ad on a user's first N messages.
|
|
65
|
+
- Frequency is client-side (in-memory per-user counter); skipped turns return your message without a server call.
|
|
66
|
+
- `NativeAdsMiddleware(...)` accepts the same `show_every` / `skip_first`.
|
|
67
|
+
- `await ads.inject(*, user_id, message, language_code=None, is_premium=None, keyboard=None, ad_button_position="bottom", parse_mode=None) -> InjectResult`
|
|
68
|
+
- `await ads.fetch(*, user_id, language_code=None, is_premium=None, parse_mode=None) -> FetchResult`
|
|
69
|
+
- `await ads.aclose()`
|
|
70
|
+
|
|
71
|
+
`InjectResult(message, has_ad, impression_id, ad, keyboard)` ·
|
|
72
|
+
`FetchResult(has_ad, impression_id, ad)` ·
|
|
73
|
+
`Ad(ad_text, button_text, button_url, ad_text_formatted)`
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# NativeAds Python examples
|
|
2
|
+
|
|
3
|
+
## `chatgpt_aiogram_bot.py`
|
|
4
|
+
A ChatGPT-wrapper Telegram bot (aiogram 3.x) monetized with NativeAds via `inject()`.
|
|
5
|
+
The LLM answer comes back with a native ad woven in and the button merged into the keyboard.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install nativeads[aiogram] openai
|
|
9
|
+
BOT_TOKEN=... OPENAI_API_KEY=... \
|
|
10
|
+
AD_API_KEY=sk_live_... AD_BASE=https://nativeads.cloud \
|
|
11
|
+
python chatgpt_aiogram_bot.py
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
| env | required | default |
|
|
15
|
+
|-----|:--------:|---------|
|
|
16
|
+
| `BOT_TOKEN` | ✅ | — |
|
|
17
|
+
| `OPENAI_API_KEY` | ✅ | — |
|
|
18
|
+
| `AD_API_KEY` | — | empty = ads off |
|
|
19
|
+
| `AD_BASE` | — | `https://nativeads.cloud` |
|
|
20
|
+
| `OPENAI_MODEL` | — | `gpt-4o-mini` |
|
|
21
|
+
| `SYSTEM_PROMPT` / `MAX_HISTORY` | — | sensible defaults |
|
|
22
|
+
|
|
23
|
+
> One long-poll per bot token — don't run two bots on the same `BOT_TOKEN` at once.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Example: a ChatGPT-wrapper Telegram bot (aiogram 3.x) monetized with NativeAds.
|
|
2
|
+
|
|
3
|
+
The LLM answer is passed through `inject()` — it comes back with a native ad woven
|
|
4
|
+
into the text and the ad button merged into the keyboard. `inject()` never raises,
|
|
5
|
+
so the ad layer can't break the bot.
|
|
6
|
+
|
|
7
|
+
Run:
|
|
8
|
+
pip install nativeads[aiogram] openai
|
|
9
|
+
BOT_TOKEN=... OPENAI_API_KEY=... \
|
|
10
|
+
AD_API_KEY=sk_live_... AD_BASE=https://nativeads.cloud \
|
|
11
|
+
python chatgpt_aiogram_bot.py
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from aiogram import Bot, Dispatcher
|
|
19
|
+
from aiogram.filters import Command, CommandStart
|
|
20
|
+
from aiogram.types import Message
|
|
21
|
+
from openai import AsyncOpenAI
|
|
22
|
+
|
|
23
|
+
from nativeads import NativeAds
|
|
24
|
+
from nativeads.middleware import NativeAdsMiddleware
|
|
25
|
+
|
|
26
|
+
BOT_TOKEN = os.environ["BOT_TOKEN"]
|
|
27
|
+
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
|
|
28
|
+
OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o-mini")
|
|
29
|
+
AD_API_KEY = os.environ.get("AD_API_KEY", "")
|
|
30
|
+
AD_BASE = os.environ.get("AD_BASE", "https://nativeads.cloud")
|
|
31
|
+
SYSTEM_PROMPT = os.environ.get(
|
|
32
|
+
"SYSTEM_PROMPT", "Ты — дружелюбный ассистент. Отвечай кратко и по делу на языке пользователя."
|
|
33
|
+
)
|
|
34
|
+
MAX_HISTORY = int(os.environ.get("MAX_HISTORY", "10"))
|
|
35
|
+
|
|
36
|
+
openai = AsyncOpenAI(api_key=OPENAI_API_KEY)
|
|
37
|
+
history: dict[int, list[dict]] = {}
|
|
38
|
+
|
|
39
|
+
dp = Dispatcher()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dp.message(CommandStart())
|
|
43
|
+
async def start(message: Message) -> None:
|
|
44
|
+
await message.answer(
|
|
45
|
+
"👋 Привет! Я ИИ-ассистент на базе ChatGPT. Спроси что угодно.\n\n/reset — сброс контекста"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dp.message(Command("reset"))
|
|
50
|
+
async def reset(message: Message) -> None:
|
|
51
|
+
history.pop(message.chat.id, None)
|
|
52
|
+
await message.answer("🧹 Контекст очищен.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dp.message()
|
|
56
|
+
async def handle(message: Message, nativeads: NativeAds | None = None) -> None:
|
|
57
|
+
if not message.text or message.text.startswith("/"):
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
chat_id = message.chat.id
|
|
61
|
+
await message.bot.send_chat_action(chat_id, "typing")
|
|
62
|
+
|
|
63
|
+
msgs = (
|
|
64
|
+
[{"role": "system", "content": SYSTEM_PROMPT}]
|
|
65
|
+
+ history.get(chat_id, [])
|
|
66
|
+
+ [{"role": "user", "content": message.text}]
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
resp = await openai.chat.completions.create(
|
|
70
|
+
model=OPENAI_MODEL, messages=msgs, temperature=0.7
|
|
71
|
+
)
|
|
72
|
+
answer = (resp.choices[0].message.content or "").strip() or "(пустой ответ)"
|
|
73
|
+
except Exception as e: # noqa: BLE001
|
|
74
|
+
await message.answer(f"⚠️ Ошибка ИИ: {e}")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
history[chat_id] = (
|
|
78
|
+
history.get(chat_id, [])
|
|
79
|
+
+ [{"role": "user", "content": message.text}, {"role": "assistant", "content": answer}]
|
|
80
|
+
)[-MAX_HISTORY:]
|
|
81
|
+
|
|
82
|
+
user = message.from_user
|
|
83
|
+
# Monetize: hand the answer to inject() — get it back with a native ad + button.
|
|
84
|
+
if nativeads is not None and user is not None:
|
|
85
|
+
result = await nativeads.inject(
|
|
86
|
+
user_id=user.id,
|
|
87
|
+
message=answer,
|
|
88
|
+
language_code=user.language_code,
|
|
89
|
+
is_premium=user.is_premium,
|
|
90
|
+
)
|
|
91
|
+
if result.has_ad:
|
|
92
|
+
print(f"injected ad → impression #{result.impression_id}")
|
|
93
|
+
await message.answer(result.message, reply_markup=result.keyboard)
|
|
94
|
+
else:
|
|
95
|
+
await message.answer(answer)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def main() -> None:
|
|
99
|
+
bot = Bot(token=BOT_TOKEN)
|
|
100
|
+
if AD_API_KEY:
|
|
101
|
+
mw = NativeAdsMiddleware(api_key=AD_API_KEY, base_url=AD_BASE)
|
|
102
|
+
dp.message.middleware(mw)
|
|
103
|
+
dp.shutdown.register(mw.aclose)
|
|
104
|
+
print(f"Python bot started. model={OPENAI_MODEL}, ads={'on' if AD_API_KEY else 'off'}")
|
|
105
|
+
await dp.start_polling(bot)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "nativeads"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "NativeAds SDK for Telegram bots — inject ads into LLM replies (aiogram / python-telegram-bot / raw)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
keywords = ["telegram", "ads", "aiogram", "python-telegram-bot", "monetization", "bot"]
|
|
9
|
+
dependencies = ["httpx>=0.25.0"]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://nativeads.cloud"
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
aiogram = ["aiogram>=3.0"]
|
|
16
|
+
ptb = ["python-telegram-bot>=20.0"]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/nativeads"]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .keyboard import merge_keyboard
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
_TELEGRAM_MAX_MESSAGE_LENGTH = 4096
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Ad:
|
|
16
|
+
ad_text: str
|
|
17
|
+
button_text: str
|
|
18
|
+
button_url: str
|
|
19
|
+
ad_text_formatted: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class InjectResult:
|
|
24
|
+
message: str
|
|
25
|
+
has_ad: bool
|
|
26
|
+
impression_id: Optional[int]
|
|
27
|
+
ad: Optional[Ad]
|
|
28
|
+
keyboard: Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class FetchResult:
|
|
33
|
+
has_ad: bool
|
|
34
|
+
impression_id: Optional[int]
|
|
35
|
+
ad: Optional[Ad]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NativeAds:
|
|
39
|
+
"""NativeAds client for Telegram bots.
|
|
40
|
+
|
|
41
|
+
``inject()`` and ``fetch()`` NEVER raise — on any error (timeout, network,
|
|
42
|
+
5xx) they return your original message/keyboard unchanged, so ads can never
|
|
43
|
+
break the bot. Default timeout is 3 seconds.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
api_key: str,
|
|
49
|
+
platform_id: Optional[str] = None,
|
|
50
|
+
*,
|
|
51
|
+
base_url: str = "https://nativeads.cloud",
|
|
52
|
+
timeout: float = 3.0,
|
|
53
|
+
platform: str = "telegram",
|
|
54
|
+
show_every: int = 1,
|
|
55
|
+
skip_first: int = 0,
|
|
56
|
+
) -> None:
|
|
57
|
+
if not api_key:
|
|
58
|
+
raise ValueError("NativeAds: api_key is required")
|
|
59
|
+
self._api_key = api_key
|
|
60
|
+
self._platform_id = platform_id
|
|
61
|
+
self._base_url = base_url.rstrip("/")
|
|
62
|
+
self._timeout = timeout
|
|
63
|
+
self._platform = platform
|
|
64
|
+
# Show an ad on every Nth message per user (1 = every; 0 = disabled).
|
|
65
|
+
# With N>1 the first N-1 messages are ad-free. skip_first never shows an
|
|
66
|
+
# ad on a user's first M messages.
|
|
67
|
+
self._show_every = show_every
|
|
68
|
+
self._skip_first = skip_first
|
|
69
|
+
self._counts: dict[int, int] = {}
|
|
70
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
71
|
+
|
|
72
|
+
def _should_show(self, user_id: int) -> bool:
|
|
73
|
+
"""Per-user message counter → whether this turn should carry an ad.
|
|
74
|
+
In-memory (per process) — cadence is approximate across restarts."""
|
|
75
|
+
n = self._counts.get(user_id, 0) + 1
|
|
76
|
+
self._counts[user_id] = n
|
|
77
|
+
if self._show_every <= 0:
|
|
78
|
+
return False
|
|
79
|
+
if n <= self._skip_first:
|
|
80
|
+
return False
|
|
81
|
+
return (n - self._skip_first) % self._show_every == 0
|
|
82
|
+
|
|
83
|
+
def _ensure_client(self) -> httpx.AsyncClient:
|
|
84
|
+
if self._client is None or self._client.is_closed:
|
|
85
|
+
self._client = httpx.AsyncClient(
|
|
86
|
+
base_url=self._base_url,
|
|
87
|
+
headers={
|
|
88
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
"User-Agent": f"nativeads-python/{__version__}",
|
|
91
|
+
},
|
|
92
|
+
timeout=httpx.Timeout(self._timeout),
|
|
93
|
+
)
|
|
94
|
+
return self._client
|
|
95
|
+
|
|
96
|
+
async def aclose(self) -> None:
|
|
97
|
+
if self._client is not None and not self._client.is_closed:
|
|
98
|
+
await self._client.aclose()
|
|
99
|
+
self._client = None
|
|
100
|
+
|
|
101
|
+
async def inject(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
user_id: int,
|
|
105
|
+
message: str,
|
|
106
|
+
language_code: Optional[str] = None,
|
|
107
|
+
is_premium: Optional[bool] = None,
|
|
108
|
+
keyboard: Any = None,
|
|
109
|
+
ad_button_position: str = "bottom",
|
|
110
|
+
parse_mode: Optional[str] = None,
|
|
111
|
+
) -> InjectResult:
|
|
112
|
+
"""Pass the LLM reply (and optionally the keyboard you were going to send);
|
|
113
|
+
get them back with a native ad injected. Never raises."""
|
|
114
|
+
fallback = InjectResult(
|
|
115
|
+
message=message, has_ad=False, impression_id=None, ad=None, keyboard=keyboard
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Frequency gate: skip this turn (no server call, no impression).
|
|
119
|
+
if not self._should_show(user_id):
|
|
120
|
+
return fallback
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
payload = {
|
|
124
|
+
"user_id": user_id,
|
|
125
|
+
"message": message,
|
|
126
|
+
"platform_id": self._platform_id,
|
|
127
|
+
"language_code": language_code or "",
|
|
128
|
+
"is_premium": bool(is_premium),
|
|
129
|
+
"platform": self._platform,
|
|
130
|
+
}
|
|
131
|
+
if parse_mode:
|
|
132
|
+
payload["parse_mode"] = parse_mode
|
|
133
|
+
|
|
134
|
+
resp = await self._ensure_client().post("/api/v1/ad", json=payload)
|
|
135
|
+
resp.raise_for_status()
|
|
136
|
+
data = resp.json()
|
|
137
|
+
|
|
138
|
+
if not data.get("has_ad") or not data.get("ad"):
|
|
139
|
+
return InjectResult(
|
|
140
|
+
message=data.get("message", message),
|
|
141
|
+
has_ad=False,
|
|
142
|
+
impression_id=data.get("impression_id"),
|
|
143
|
+
ad=None,
|
|
144
|
+
keyboard=keyboard,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
raw = data["ad"]
|
|
148
|
+
ad = Ad(
|
|
149
|
+
ad_text=raw["ad_text"],
|
|
150
|
+
button_text=raw["button_text"],
|
|
151
|
+
button_url=raw["button_url"],
|
|
152
|
+
ad_text_formatted=raw.get("ad_text_formatted"),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
result_message = data.get("message", message)
|
|
156
|
+
if len(result_message) > _TELEGRAM_MAX_MESSAGE_LENGTH:
|
|
157
|
+
result_message = message
|
|
158
|
+
|
|
159
|
+
result_keyboard = keyboard
|
|
160
|
+
if ad.button_text and ad.button_url:
|
|
161
|
+
result_keyboard = merge_keyboard(
|
|
162
|
+
keyboard, ad.button_text, ad.button_url, ad_button_position
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if result_keyboard is keyboard and result_message == message:
|
|
166
|
+
return fallback
|
|
167
|
+
|
|
168
|
+
return InjectResult(
|
|
169
|
+
message=result_message,
|
|
170
|
+
has_ad=True,
|
|
171
|
+
impression_id=data.get("impression_id"),
|
|
172
|
+
ad=ad,
|
|
173
|
+
keyboard=result_keyboard,
|
|
174
|
+
)
|
|
175
|
+
except Exception:
|
|
176
|
+
return fallback
|
|
177
|
+
|
|
178
|
+
async def fetch(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
user_id: int,
|
|
182
|
+
language_code: Optional[str] = None,
|
|
183
|
+
is_premium: Optional[bool] = None,
|
|
184
|
+
parse_mode: Optional[str] = None,
|
|
185
|
+
) -> FetchResult:
|
|
186
|
+
"""Privacy-friendly alternative: get the ad as separate fields without
|
|
187
|
+
sending the LLM reply text. You render it yourself. Never raises."""
|
|
188
|
+
empty = FetchResult(has_ad=False, impression_id=None, ad=None)
|
|
189
|
+
try:
|
|
190
|
+
payload = {
|
|
191
|
+
"user_id": user_id,
|
|
192
|
+
"platform_id": self._platform_id,
|
|
193
|
+
"language_code": language_code or "",
|
|
194
|
+
"is_premium": bool(is_premium),
|
|
195
|
+
"platform": self._platform,
|
|
196
|
+
}
|
|
197
|
+
if parse_mode:
|
|
198
|
+
payload["parse_mode"] = parse_mode
|
|
199
|
+
|
|
200
|
+
resp = await self._ensure_client().post("/api/v1/ad/fetch", json=payload)
|
|
201
|
+
resp.raise_for_status()
|
|
202
|
+
data = resp.json()
|
|
203
|
+
|
|
204
|
+
if not data.get("has_ad") or not data.get("ad"):
|
|
205
|
+
return empty
|
|
206
|
+
|
|
207
|
+
raw = data["ad"]
|
|
208
|
+
return FetchResult(
|
|
209
|
+
has_ad=True,
|
|
210
|
+
impression_id=data.get("impression_id"),
|
|
211
|
+
ad=Ad(
|
|
212
|
+
ad_text=raw["ad_text"],
|
|
213
|
+
button_text=raw["button_text"],
|
|
214
|
+
button_url=raw["button_url"],
|
|
215
|
+
ad_text_formatted=raw.get("ad_text_formatted"),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
except Exception:
|
|
219
|
+
return empty
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# Deprecated alias, kept for compatibility with code written against the old name.
|
|
223
|
+
AibotAds = NativeAds
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Framework-aware inline-keyboard merging.
|
|
2
|
+
|
|
3
|
+
Adds the ad button as its own row, never touching the publisher's buttons.
|
|
4
|
+
Supports aiogram 3.x, python-telegram-bot v20+, and the raw Telegram dict shape.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
# Telegram's hard limit on inline keyboard rows.
|
|
11
|
+
_MAX_ROWS = 13
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _aiogram_types() -> Optional[Tuple[Any, Any]]:
|
|
15
|
+
try:
|
|
16
|
+
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
17
|
+
return InlineKeyboardMarkup, InlineKeyboardButton
|
|
18
|
+
except ImportError:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ptb_types() -> Optional[Tuple[Any, Any]]:
|
|
23
|
+
try:
|
|
24
|
+
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
|
25
|
+
return InlineKeyboardMarkup, InlineKeyboardButton
|
|
26
|
+
except ImportError:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def merge_keyboard(keyboard: Any, button_text: str, button_url: str, position: str = "bottom") -> Any:
|
|
31
|
+
"""Return a new keyboard with the ad button row inserted (top/bottom), in the
|
|
32
|
+
same framework type it was given. Returns the original unchanged when the row
|
|
33
|
+
limit is reached or the shape is unrecognized."""
|
|
34
|
+
aiogram = _aiogram_types()
|
|
35
|
+
ptb = _ptb_types()
|
|
36
|
+
|
|
37
|
+
# --- existing aiogram markup ---
|
|
38
|
+
if aiogram is not None and isinstance(keyboard, aiogram[0]):
|
|
39
|
+
markup_cls, button_cls = aiogram
|
|
40
|
+
rows = list(keyboard.inline_keyboard)
|
|
41
|
+
if len(rows) >= _MAX_ROWS:
|
|
42
|
+
return keyboard
|
|
43
|
+
ad_row = [button_cls(text=button_text, url=button_url)]
|
|
44
|
+
rows = [ad_row] + rows if position == "top" else rows + [ad_row]
|
|
45
|
+
return markup_cls(inline_keyboard=rows)
|
|
46
|
+
|
|
47
|
+
# --- existing python-telegram-bot markup ---
|
|
48
|
+
if ptb is not None and isinstance(keyboard, ptb[0]):
|
|
49
|
+
markup_cls, button_cls = ptb
|
|
50
|
+
rows = [list(r) for r in keyboard.inline_keyboard]
|
|
51
|
+
if len(rows) >= _MAX_ROWS:
|
|
52
|
+
return keyboard
|
|
53
|
+
ad_row = [button_cls(text=button_text, url=button_url)]
|
|
54
|
+
rows = [ad_row] + rows if position == "top" else rows + [ad_row]
|
|
55
|
+
return markup_cls(rows)
|
|
56
|
+
|
|
57
|
+
# --- existing raw dict markup ---
|
|
58
|
+
if isinstance(keyboard, dict) and isinstance(keyboard.get("inline_keyboard"), list):
|
|
59
|
+
rows = list(keyboard["inline_keyboard"])
|
|
60
|
+
if len(rows) >= _MAX_ROWS:
|
|
61
|
+
return keyboard
|
|
62
|
+
ad_row = [{"text": button_text, "url": button_url}]
|
|
63
|
+
rows = [ad_row] + rows if position == "top" else rows + [ad_row]
|
|
64
|
+
return {**keyboard, "inline_keyboard": rows}
|
|
65
|
+
|
|
66
|
+
# --- no keyboard → build a fresh one in whatever framework is available ---
|
|
67
|
+
if keyboard is None:
|
|
68
|
+
if aiogram is not None:
|
|
69
|
+
markup_cls, button_cls = aiogram
|
|
70
|
+
return markup_cls(inline_keyboard=[[button_cls(text=button_text, url=button_url)]])
|
|
71
|
+
if ptb is not None:
|
|
72
|
+
markup_cls, button_cls = ptb
|
|
73
|
+
return markup_cls([[button_cls(text=button_text, url=button_url)]])
|
|
74
|
+
return {"inline_keyboard": [[{"text": button_text, "url": button_url}]]}
|
|
75
|
+
|
|
76
|
+
# Unrecognized shape — never break the bot.
|
|
77
|
+
return keyboard
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Optional aiogram 3.x middleware that injects a configured ``NativeAds`` into
|
|
2
|
+
handler data, so handlers receive it via dependency injection.
|
|
3
|
+
|
|
4
|
+
from nativeads import NativeAds
|
|
5
|
+
from nativeads.middleware import NativeAdsMiddleware
|
|
6
|
+
|
|
7
|
+
mw = NativeAdsMiddleware(api_key="sk_live_...", platform_id="plt_...")
|
|
8
|
+
dp.message.middleware(mw)
|
|
9
|
+
dp.shutdown.register(mw.aclose)
|
|
10
|
+
|
|
11
|
+
async def handler(message: Message, nativeads: NativeAds):
|
|
12
|
+
result = await nativeads.inject(user_id=message.from_user.id, message=answer)
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
17
|
+
|
|
18
|
+
from .client import NativeAds
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NativeAdsMiddleware:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
api_key: Optional[str] = None,
|
|
25
|
+
platform_id: Optional[str] = None,
|
|
26
|
+
*,
|
|
27
|
+
client: Optional[NativeAds] = None,
|
|
28
|
+
base_url: str = "https://nativeads.cloud",
|
|
29
|
+
timeout: float = 3.0,
|
|
30
|
+
platform: str = "telegram",
|
|
31
|
+
show_every: int = 1,
|
|
32
|
+
skip_first: int = 0,
|
|
33
|
+
) -> None:
|
|
34
|
+
if client is not None:
|
|
35
|
+
self.client = client
|
|
36
|
+
self._owned = False
|
|
37
|
+
else:
|
|
38
|
+
if not api_key:
|
|
39
|
+
raise TypeError("NativeAdsMiddleware requires api_key or an existing client=")
|
|
40
|
+
self.client = NativeAds(
|
|
41
|
+
api_key=api_key,
|
|
42
|
+
platform_id=platform_id,
|
|
43
|
+
base_url=base_url,
|
|
44
|
+
timeout=timeout,
|
|
45
|
+
platform=platform,
|
|
46
|
+
show_every=show_every,
|
|
47
|
+
skip_first=skip_first,
|
|
48
|
+
)
|
|
49
|
+
self._owned = True
|
|
50
|
+
|
|
51
|
+
async def aclose(self) -> None:
|
|
52
|
+
if self._owned:
|
|
53
|
+
await self.client.aclose()
|
|
54
|
+
|
|
55
|
+
async def __call__(
|
|
56
|
+
self,
|
|
57
|
+
handler: Callable[[Any, Dict[str, Any]], Awaitable[Any]],
|
|
58
|
+
event: Any,
|
|
59
|
+
data: Dict[str, Any],
|
|
60
|
+
) -> Any:
|
|
61
|
+
# Inject under the new key; keep the old key for backward compatibility.
|
|
62
|
+
data["nativeads"] = self.client
|
|
63
|
+
data["aibotads"] = self.client
|
|
64
|
+
return await handler(event, data)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Deprecated alias, kept for compatibility with code written against the old name.
|
|
68
|
+
AibotAdsMiddleware = NativeAdsMiddleware
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Integration + unit test for the NativeAds Python SDK against a local API.
|
|
2
|
+
|
|
3
|
+
AD_API_KEY=sk_live_... BASE_URL=http://localhost:3000 python tests/run.py
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from nativeads import NativeAds, merge_keyboard
|
|
10
|
+
|
|
11
|
+
API_KEY = os.environ.get("AD_API_KEY")
|
|
12
|
+
BASE_URL = os.environ.get("BASE_URL", "http://localhost:3000")
|
|
13
|
+
if not API_KEY:
|
|
14
|
+
sys.exit("set AD_API_KEY")
|
|
15
|
+
|
|
16
|
+
passed = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ok(name: str) -> None:
|
|
20
|
+
global passed
|
|
21
|
+
passed += 1
|
|
22
|
+
print(f" \033[32m✓\033[0m {name}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _aiogram_installed() -> bool:
|
|
26
|
+
try:
|
|
27
|
+
import aiogram.types # noqa: F401
|
|
28
|
+
return True
|
|
29
|
+
except ImportError:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_keyboard_unit() -> None:
|
|
34
|
+
print("\n— unit: merge_keyboard (raw dict) —")
|
|
35
|
+
|
|
36
|
+
# None → fresh keyboard. Shape depends on the installed framework.
|
|
37
|
+
k = merge_keyboard(None, "Ad", "u", "bottom")
|
|
38
|
+
if _aiogram_installed():
|
|
39
|
+
from aiogram.types import InlineKeyboardMarkup as AG
|
|
40
|
+
assert isinstance(k, AG) and len(k.inline_keyboard) == 1
|
|
41
|
+
ok("None keyboard → fresh aiogram markup (framework present)")
|
|
42
|
+
else:
|
|
43
|
+
assert k == {"inline_keyboard": [[{"text": "Ad", "url": "u"}]]}
|
|
44
|
+
ok("None keyboard → raw dict (core-only)")
|
|
45
|
+
|
|
46
|
+
raw = {"inline_keyboard": [[{"text": "A", "url": "a"}], [{"text": "B", "url": "b"}]]}
|
|
47
|
+
m = merge_keyboard(raw, "Ad", "u", "bottom")
|
|
48
|
+
assert len(m["inline_keyboard"]) == 3
|
|
49
|
+
assert m["inline_keyboard"][0] == [{"text": "A", "url": "a"}]
|
|
50
|
+
assert m["inline_keyboard"][2] == [{"text": "Ad", "url": "u"}]
|
|
51
|
+
assert len(raw["inline_keyboard"]) == 2, "original not mutated"
|
|
52
|
+
ok("raw keyboard: ad appended last, originals intact")
|
|
53
|
+
|
|
54
|
+
t = merge_keyboard(raw, "Ad", "u", "top")
|
|
55
|
+
assert t["inline_keyboard"][0] == [{"text": "Ad", "url": "u"}]
|
|
56
|
+
ok("position=top puts ad first")
|
|
57
|
+
|
|
58
|
+
full = {"inline_keyboard": [[{"text": str(i), "url": "x"}] for i in range(13)]}
|
|
59
|
+
assert merge_keyboard(full, "Ad", "u") is full
|
|
60
|
+
ok("13-row limit respected (returns original)")
|
|
61
|
+
|
|
62
|
+
weird = {"foo": "bar"}
|
|
63
|
+
assert merge_keyboard(weird, "Ad", "u") is weird
|
|
64
|
+
ok("unknown keyboard shape returned unchanged")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_keyboard_aiogram() -> None:
|
|
68
|
+
try:
|
|
69
|
+
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
70
|
+
except ImportError:
|
|
71
|
+
print("\n— unit: aiogram keyboard — (skipped: aiogram not installed)")
|
|
72
|
+
return
|
|
73
|
+
print("\n— unit: merge_keyboard (aiogram) —")
|
|
74
|
+
kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Mine", url="https://me")]])
|
|
75
|
+
merged = merge_keyboard(kb, "Ad", "https://ad", "bottom")
|
|
76
|
+
assert isinstance(merged, InlineKeyboardMarkup)
|
|
77
|
+
assert len(merged.inline_keyboard) == 2
|
|
78
|
+
assert merged.inline_keyboard[0][0].text == "Mine", "publisher button preserved"
|
|
79
|
+
assert merged.inline_keyboard[1][0].text == "Ad", "ad row at bottom"
|
|
80
|
+
ok("aiogram InlineKeyboardMarkup merged, type preserved")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def test_integration() -> None:
|
|
84
|
+
ads = NativeAds(API_KEY, platform_id="plt_localtest", base_url=BASE_URL)
|
|
85
|
+
|
|
86
|
+
print("\n— integration: inject —")
|
|
87
|
+
r = await ads.inject(user_id=800001, message="Столица Японии — Токио.", language_code="ru")
|
|
88
|
+
assert r.has_ad, "has ad"
|
|
89
|
+
assert r.impression_id, "impression id"
|
|
90
|
+
assert r.message.startswith("Столица Японии — Токио."), "original text preserved"
|
|
91
|
+
assert len(r.message) > len("Столица Японии — Токио."), "ad text appended"
|
|
92
|
+
assert "/api/v1/click/" in r.ad.button_url
|
|
93
|
+
ok(f"inject no-keyboard → ad injected (impression #{r.impression_id})")
|
|
94
|
+
|
|
95
|
+
existing = {"inline_keyboard": [[{"text": "Сайт", "url": "https://me.example"}]]}
|
|
96
|
+
r2 = await ads.inject(user_id=800002, message="Ответ.", keyboard=existing)
|
|
97
|
+
assert r2.has_ad
|
|
98
|
+
assert len(r2.keyboard["inline_keyboard"]) == 2, "existing + ad row"
|
|
99
|
+
assert r2.keyboard["inline_keyboard"][0][0]["text"] == "Сайт", "publisher button intact"
|
|
100
|
+
ok("inject with existing keyboard → ad row added, publisher button intact")
|
|
101
|
+
|
|
102
|
+
print("\n— integration: fetch —")
|
|
103
|
+
f = await ads.fetch(user_id=800003, language_code="ru")
|
|
104
|
+
assert f.has_ad and f.ad.ad_text and f.ad.button_url
|
|
105
|
+
ok(f"fetch → ad object (impression #{f.impression_id})")
|
|
106
|
+
|
|
107
|
+
print("\n— resilience: never raises —")
|
|
108
|
+
dead = NativeAds(API_KEY, base_url="http://127.0.0.1:59999", timeout=1.5)
|
|
109
|
+
kb = {"inline_keyboard": [[{"text": "Keep", "url": "k"}]]}
|
|
110
|
+
rd = await dead.inject(user_id=800004, message="original", keyboard=kb)
|
|
111
|
+
assert rd.has_ad is False and rd.message == "original" and rd.keyboard is kb
|
|
112
|
+
ok("inject() swallows network error, returns original")
|
|
113
|
+
fd = await dead.fetch(user_id=800005, language_code="ru")
|
|
114
|
+
assert fd.has_ad is False
|
|
115
|
+
ok("fetch() swallows network error")
|
|
116
|
+
|
|
117
|
+
print("\n— frequency: show_every / skip_first —")
|
|
118
|
+
every3 = NativeAds(API_KEY, base_url=BASE_URL, show_every=3)
|
|
119
|
+
seen = [(await every3.inject(user_id=800100, message="q")).has_ad for _ in range(6)]
|
|
120
|
+
assert seen == [False, False, True, False, False, True], "ad on every 3rd message"
|
|
121
|
+
ok("show_every=3 → ad on messages 3 and 6 (first message ad-free)")
|
|
122
|
+
await every3.aclose()
|
|
123
|
+
|
|
124
|
+
skip1 = NativeAds(API_KEY, base_url=BASE_URL, show_every=1, skip_first=1)
|
|
125
|
+
seen2 = [(await skip1.inject(user_id=800101, message="q")).has_ad for _ in range(3)]
|
|
126
|
+
assert seen2 == [False, True, True], "first message skipped, then every"
|
|
127
|
+
ok("skip_first=1 → no ad on the first message")
|
|
128
|
+
await skip1.aclose()
|
|
129
|
+
|
|
130
|
+
off = NativeAds(API_KEY, base_url=BASE_URL, show_every=0)
|
|
131
|
+
assert (await off.inject(user_id=800102, message="q")).has_ad is False
|
|
132
|
+
ok("show_every=0 → ads disabled")
|
|
133
|
+
await off.aclose()
|
|
134
|
+
|
|
135
|
+
await ads.aclose()
|
|
136
|
+
await dead.aclose()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main() -> None:
|
|
140
|
+
test_keyboard_unit()
|
|
141
|
+
test_keyboard_aiogram()
|
|
142
|
+
asyncio.run(test_integration())
|
|
143
|
+
print(f"\n\033[32mALL {passed} CHECKS PASSED\033[0m\n")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
main()
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Offline unit tests for the NativeAds Python SDK — no server or API key needed.
|
|
2
|
+
|
|
3
|
+
The httpx transport is mocked so every branch is deterministic.
|
|
4
|
+
|
|
5
|
+
python tests/unit.py
|
|
6
|
+
"""
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from nativeads import Ad, AibotAds, NativeAds, merge_keyboard
|
|
14
|
+
from nativeads import client as client_mod
|
|
15
|
+
from nativeads.middleware import AibotAdsMiddleware, NativeAdsMiddleware
|
|
16
|
+
|
|
17
|
+
passed = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ok(name: str) -> None:
|
|
21
|
+
global passed
|
|
22
|
+
passed += 1
|
|
23
|
+
print(f" \033[32m✓\033[0m {name}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
AD = {
|
|
27
|
+
"ad_text": "Try Acme — the best widget.",
|
|
28
|
+
"button_text": "Open Acme",
|
|
29
|
+
"button_url": "https://nativeads.cloud/api/v1/click/42",
|
|
30
|
+
"ad_text_formatted": "<b>Try Acme</b>",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ad_response(**over):
|
|
35
|
+
body = {
|
|
36
|
+
"has_ad": True,
|
|
37
|
+
"impression_id": 42,
|
|
38
|
+
"message": "Paris is the capital of France.\n\nTry Acme — the best widget.",
|
|
39
|
+
"ad": AD,
|
|
40
|
+
}
|
|
41
|
+
body.update(over)
|
|
42
|
+
return body
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _Patch:
|
|
46
|
+
"""Force every httpx.AsyncClient the SDK builds to use a MockTransport whose
|
|
47
|
+
handler returns `responder(request)`. Records every request for assertions."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, responder):
|
|
50
|
+
self.responder = responder
|
|
51
|
+
self.requests = []
|
|
52
|
+
self._orig = httpx.AsyncClient
|
|
53
|
+
|
|
54
|
+
def _handler(self, request: httpx.Request) -> httpx.Response:
|
|
55
|
+
self.requests.append(request)
|
|
56
|
+
out = self.responder(request)
|
|
57
|
+
if isinstance(out, httpx.Response):
|
|
58
|
+
return out
|
|
59
|
+
return httpx.Response(200, json=out)
|
|
60
|
+
|
|
61
|
+
def __enter__(self):
|
|
62
|
+
transport = httpx.MockTransport(self._handler)
|
|
63
|
+
orig = self._orig
|
|
64
|
+
|
|
65
|
+
def factory(*args, **kwargs):
|
|
66
|
+
kwargs["transport"] = transport
|
|
67
|
+
return orig(*args, **kwargs)
|
|
68
|
+
|
|
69
|
+
client_mod.httpx.AsyncClient = factory
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, *exc):
|
|
73
|
+
client_mod.httpx.AsyncClient = self._orig
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def body_of(request: httpx.Request) -> dict:
|
|
78
|
+
return json.loads(request.content)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def rows_of(keyboard) -> list:
|
|
82
|
+
"""Normalize a keyboard (raw dict / aiogram / PTB markup) to a list of rows
|
|
83
|
+
of (text, url) tuples, so assertions don't depend on the installed framework."""
|
|
84
|
+
if isinstance(keyboard, dict):
|
|
85
|
+
rows = keyboard["inline_keyboard"]
|
|
86
|
+
else:
|
|
87
|
+
rows = keyboard.inline_keyboard # aiogram / PTB markup
|
|
88
|
+
out = []
|
|
89
|
+
for row in rows:
|
|
90
|
+
norm = []
|
|
91
|
+
for btn in row:
|
|
92
|
+
if isinstance(btn, dict):
|
|
93
|
+
norm.append((btn["text"], btn["url"]))
|
|
94
|
+
else:
|
|
95
|
+
norm.append((btn.text, btn.url))
|
|
96
|
+
out.append(norm)
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- sync tests ---
|
|
101
|
+
|
|
102
|
+
def test_constructor_requires_api_key():
|
|
103
|
+
try:
|
|
104
|
+
NativeAds("")
|
|
105
|
+
assert False, "expected ValueError"
|
|
106
|
+
except ValueError as e:
|
|
107
|
+
assert "api_key" in str(e)
|
|
108
|
+
ok("constructor requires api_key")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_backward_compat_alias():
|
|
112
|
+
assert AibotAds is NativeAds
|
|
113
|
+
assert AibotAdsMiddleware is NativeAdsMiddleware
|
|
114
|
+
ok("AibotAds / AibotAdsMiddleware are aliases for the NativeAds names")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_default_base_url():
|
|
118
|
+
ads = NativeAds("sk_live_x")
|
|
119
|
+
assert ads._base_url == "https://nativeads.cloud", ads._base_url
|
|
120
|
+
ok("default base_url is https://nativeads.cloud")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_base_url_trailing_slash_trimmed():
|
|
124
|
+
ads = NativeAds("sk", base_url="https://example.com///")
|
|
125
|
+
assert ads._base_url == "https://example.com"
|
|
126
|
+
ok("trailing slashes trimmed from base_url")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_merge_keyboard_raw():
|
|
130
|
+
k = merge_keyboard(None, "Ad", "u", "bottom")
|
|
131
|
+
# core-only env → raw dict; with a framework installed → framework markup.
|
|
132
|
+
if isinstance(k, dict):
|
|
133
|
+
assert k == {"inline_keyboard": [[{"text": "Ad", "url": "u"}]]}
|
|
134
|
+
|
|
135
|
+
raw = {"inline_keyboard": [[{"text": "A", "url": "a"}], [{"text": "B", "url": "b"}]]}
|
|
136
|
+
m = merge_keyboard(raw, "Ad", "u", "bottom")
|
|
137
|
+
assert len(m["inline_keyboard"]) == 3
|
|
138
|
+
assert m["inline_keyboard"][2] == [{"text": "Ad", "url": "u"}]
|
|
139
|
+
assert len(raw["inline_keyboard"]) == 2, "original not mutated"
|
|
140
|
+
|
|
141
|
+
t = merge_keyboard(raw, "Ad", "u", "top")
|
|
142
|
+
assert t["inline_keyboard"][0] == [{"text": "Ad", "url": "u"}]
|
|
143
|
+
|
|
144
|
+
full = {"inline_keyboard": [[{"text": str(i), "url": "x"}] for i in range(13)]}
|
|
145
|
+
assert merge_keyboard(full, "Ad", "u") is full
|
|
146
|
+
|
|
147
|
+
weird = {"foo": "bar"}
|
|
148
|
+
assert merge_keyboard(weird, "Ad", "u") is weird
|
|
149
|
+
ok("merge_keyboard (raw dict): append/top/limit/unknown all correct")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --- async tests ---
|
|
153
|
+
|
|
154
|
+
async def test_inject_payload_and_headers():
|
|
155
|
+
with _Patch(lambda req: ad_response()) as p:
|
|
156
|
+
ads = NativeAds("sk_live_abc", platform_id="plt_1", platform="discord")
|
|
157
|
+
await ads.inject(
|
|
158
|
+
user_id=555, message="hello", language_code="ru",
|
|
159
|
+
is_premium=True, parse_mode="HTML",
|
|
160
|
+
)
|
|
161
|
+
await ads.aclose()
|
|
162
|
+
req = p.requests[0]
|
|
163
|
+
assert str(req.url) == "https://nativeads.cloud/api/v1/ad", str(req.url)
|
|
164
|
+
assert req.headers["authorization"] == "Bearer sk_live_abc"
|
|
165
|
+
assert req.headers["user-agent"].startswith("nativeads-python/")
|
|
166
|
+
assert body_of(req) == {
|
|
167
|
+
"user_id": 555, "message": "hello", "platform_id": "plt_1",
|
|
168
|
+
"language_code": "ru", "is_premium": True, "platform": "discord",
|
|
169
|
+
"parse_mode": "HTML",
|
|
170
|
+
}
|
|
171
|
+
ok("inject sends correct URL, auth header and payload")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def test_inject_weaves_ad_and_keyboard():
|
|
175
|
+
with _Patch(lambda req: ad_response()):
|
|
176
|
+
ads = NativeAds("sk")
|
|
177
|
+
r = await ads.inject(user_id=1, message="Paris is the capital of France.")
|
|
178
|
+
await ads.aclose()
|
|
179
|
+
assert r.has_ad and r.impression_id == 42
|
|
180
|
+
assert r.message == ad_response()["message"]
|
|
181
|
+
assert isinstance(r.ad, Ad) and r.ad.button_url == AD["button_url"]
|
|
182
|
+
# Shape adapts to the installed framework; compare normalized rows.
|
|
183
|
+
assert rows_of(r.keyboard) == [[("Open Acme", AD["button_url"])]]
|
|
184
|
+
ok("inject weaves ad text + adds ad button row")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def test_inject_preserves_existing_keyboard():
|
|
188
|
+
with _Patch(lambda req: ad_response()):
|
|
189
|
+
ads = NativeAds("sk")
|
|
190
|
+
existing = {"inline_keyboard": [[{"text": "My site", "url": "https://me"}]]}
|
|
191
|
+
r = await ads.inject(user_id=1, message="x", keyboard=existing)
|
|
192
|
+
await ads.aclose()
|
|
193
|
+
assert len(r.keyboard["inline_keyboard"]) == 2
|
|
194
|
+
assert r.keyboard["inline_keyboard"][0] == [{"text": "My site", "url": "https://me"}]
|
|
195
|
+
assert len(existing["inline_keyboard"]) == 1, "caller keyboard not mutated"
|
|
196
|
+
ok("inject preserves existing keyboard, appends ad row")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def test_inject_no_ad():
|
|
200
|
+
with _Patch(lambda req: {"has_ad": False, "impression_id": 7, "ad": None}):
|
|
201
|
+
ads = NativeAds("sk")
|
|
202
|
+
kb = {"inline_keyboard": [[{"text": "Keep", "url": "k"}]]}
|
|
203
|
+
r = await ads.inject(user_id=1, message="original", keyboard=kb)
|
|
204
|
+
await ads.aclose()
|
|
205
|
+
assert r.has_ad is False and r.message == "original"
|
|
206
|
+
assert r.impression_id == 7 and r.keyboard is kb
|
|
207
|
+
ok("inject: no ad → original message, impression_id passthrough")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def test_inject_message_too_long():
|
|
211
|
+
long_msg = "x" * 5000
|
|
212
|
+
with _Patch(lambda req: ad_response(message=long_msg)):
|
|
213
|
+
ads = NativeAds("sk")
|
|
214
|
+
r = await ads.inject(user_id=1, message="original short")
|
|
215
|
+
await ads.aclose()
|
|
216
|
+
assert r.has_ad is True
|
|
217
|
+
assert r.message == "original short", "falls back to original when too long"
|
|
218
|
+
assert rows_of(r.keyboard) == [[("Open Acme", AD["button_url"])]], "button still attached"
|
|
219
|
+
ok("inject: server message > 4096 → keep original text but attach button")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def test_inject_http_500():
|
|
223
|
+
with _Patch(lambda req: httpx.Response(500, json={"error": "boom"})):
|
|
224
|
+
ads = NativeAds("sk")
|
|
225
|
+
kb = {"inline_keyboard": [[{"text": "Keep", "url": "k"}]]}
|
|
226
|
+
r = await ads.inject(user_id=1, message="original", keyboard=kb)
|
|
227
|
+
await ads.aclose()
|
|
228
|
+
assert r.has_ad is False and r.message == "original" and r.keyboard is kb
|
|
229
|
+
ok("inject: HTTP 500 → silent fallback (never raises)")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
async def test_inject_network_error():
|
|
233
|
+
def boom(req):
|
|
234
|
+
raise httpx.ConnectError("refused", request=req)
|
|
235
|
+
with _Patch(boom):
|
|
236
|
+
ads = NativeAds("sk")
|
|
237
|
+
r = await ads.inject(user_id=1, message="original")
|
|
238
|
+
await ads.aclose()
|
|
239
|
+
assert r.has_ad is False and r.message == "original"
|
|
240
|
+
ok("inject: network error → silent fallback")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def test_fetch():
|
|
244
|
+
with _Patch(lambda req: ad_response()) as p:
|
|
245
|
+
ads = NativeAds("sk")
|
|
246
|
+
r = await ads.fetch(user_id=9, language_code="en")
|
|
247
|
+
await ads.aclose()
|
|
248
|
+
assert str(p.requests[0].url) == "https://nativeads.cloud/api/v1/ad/fetch"
|
|
249
|
+
assert "message" not in body_of(p.requests[0]), "fetch never sends reply text"
|
|
250
|
+
assert r.has_ad and r.ad.ad_text == AD["ad_text"]
|
|
251
|
+
ok("fetch returns ad fields without sending message text")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def test_fetch_error():
|
|
255
|
+
def boom(req):
|
|
256
|
+
raise httpx.ConnectTimeout("timeout", request=req)
|
|
257
|
+
with _Patch(boom):
|
|
258
|
+
ads = NativeAds("sk")
|
|
259
|
+
r = await ads.fetch(user_id=9)
|
|
260
|
+
await ads.aclose()
|
|
261
|
+
assert r.has_ad is False and r.impression_id is None and r.ad is None
|
|
262
|
+
ok("fetch: error → empty result")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def test_frequency_show_every():
|
|
266
|
+
with _Patch(lambda req: ad_response()) as p:
|
|
267
|
+
ads = NativeAds("sk", show_every=3)
|
|
268
|
+
seen = [(await ads.inject(user_id=1, message="q")).has_ad for _ in range(6)]
|
|
269
|
+
await ads.aclose()
|
|
270
|
+
assert seen == [False, False, True, False, False, True]
|
|
271
|
+
assert len(p.requests) == 2, "server only called on the 2 ad turns"
|
|
272
|
+
ok("show_every=3 → ad on turns 3 and 6, server skipped otherwise")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def test_frequency_skip_first():
|
|
276
|
+
with _Patch(lambda req: ad_response()):
|
|
277
|
+
ads = NativeAds("sk", skip_first=2)
|
|
278
|
+
seen = [(await ads.inject(user_id=1, message="q")).has_ad for _ in range(4)]
|
|
279
|
+
await ads.aclose()
|
|
280
|
+
assert seen == [False, False, True, True]
|
|
281
|
+
ok("skip_first=2 → first two turns ad-free")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def test_frequency_off():
|
|
285
|
+
with _Patch(lambda req: ad_response()) as p:
|
|
286
|
+
ads = NativeAds("sk", show_every=0)
|
|
287
|
+
r = await ads.inject(user_id=1, message="q")
|
|
288
|
+
await ads.aclose()
|
|
289
|
+
assert r.has_ad is False and len(p.requests) == 0
|
|
290
|
+
ok("show_every=0 → ads disabled, no server call")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def test_frequency_per_user():
|
|
294
|
+
with _Patch(lambda req: ad_response()):
|
|
295
|
+
ads = NativeAds("sk", show_every=2)
|
|
296
|
+
assert (await ads.inject(user_id=1, message="q")).has_ad is False
|
|
297
|
+
assert (await ads.inject(user_id=2, message="q")).has_ad is False
|
|
298
|
+
assert (await ads.inject(user_id=1, message="q")).has_ad is True
|
|
299
|
+
assert (await ads.inject(user_id=2, message="q")).has_ad is True
|
|
300
|
+
await ads.aclose()
|
|
301
|
+
ok("frequency counters are independent per user")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def run_async():
|
|
305
|
+
for fn in (
|
|
306
|
+
test_inject_payload_and_headers,
|
|
307
|
+
test_inject_weaves_ad_and_keyboard,
|
|
308
|
+
test_inject_preserves_existing_keyboard,
|
|
309
|
+
test_inject_no_ad,
|
|
310
|
+
test_inject_message_too_long,
|
|
311
|
+
test_inject_http_500,
|
|
312
|
+
test_inject_network_error,
|
|
313
|
+
test_fetch,
|
|
314
|
+
test_fetch_error,
|
|
315
|
+
test_frequency_show_every,
|
|
316
|
+
test_frequency_skip_first,
|
|
317
|
+
test_frequency_off,
|
|
318
|
+
test_frequency_per_user,
|
|
319
|
+
):
|
|
320
|
+
await fn()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def main():
|
|
324
|
+
print("\n— sync —")
|
|
325
|
+
test_constructor_requires_api_key()
|
|
326
|
+
test_backward_compat_alias()
|
|
327
|
+
test_default_base_url()
|
|
328
|
+
test_base_url_trailing_slash_trimmed()
|
|
329
|
+
test_merge_keyboard_raw()
|
|
330
|
+
print("\n— async (mocked httpx) —")
|
|
331
|
+
asyncio.run(run_async())
|
|
332
|
+
print(f"\n\033[32mALL {passed} CHECKS PASSED\033[0m\n")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
main()
|