nativeads 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"
nativeads/client.py ADDED
@@ -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
nativeads/keyboard.py ADDED
@@ -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,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,7 @@
1
+ nativeads/__init__.py,sha256=KZXVut1Xm0NbaZANnBbChoxD10gUSlT61oDv9tVMeTY,222
2
+ nativeads/client.py,sha256=8gEV20ldCtmomzGlx60B7wWtED3AqOKxsWPc0LKeXQ4,7309
3
+ nativeads/keyboard.py,sha256=lKj52LETxyU61b8r1h0W10YMj5RW1H_p_l6jv09Ms8U,3081
4
+ nativeads/middleware.py,sha256=-Fryc0pTdz4RICazHl__Rh9L6jE752o4wmc4O209yW0,2215
5
+ nativeads-0.1.0.dist-info/METADATA,sha256=NTeuJ_xiYraT8XDT6jYGBdppchJ_dpZ-hnX0iSoWXgQ,3545
6
+ nativeads-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ nativeads-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any