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.
@@ -0,0 +1,6 @@
1
+ .venv*/
2
+ __pycache__/
3
+ *.egg-info/
4
+ *.pyc
5
+ dist/
6
+ build/
@@ -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,5 @@
1
+ from .client import AibotAds, Ad, FetchResult, InjectResult, NativeAds
2
+ from .keyboard import merge_keyboard
3
+
4
+ __all__ = ["NativeAds", "AibotAds", "Ad", "InjectResult", "FetchResult", "merge_keyboard"]
5
+ __version__ = "0.1.0"
@@ -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()