screwinboxads 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ .claude/settings.local.json
2
+
3
+ # Backend
4
+ backend/screwinboxads
5
+ backend/screwinboxads.exe
6
+ backend/coverage.out
7
+
8
+ # Frontend
9
+ frontend/node_modules/
10
+ frontend/dist/
11
+ frontend/coverage/
12
+
13
+ # E2E
14
+ e2e/node_modules/
15
+ e2e/test-results/
16
+ e2e/playwright-report/
17
+
18
+ # Env (deploy/.env.local и .env.example коммитятся — безопасные значения для локали)
19
+ deploy/.env
20
+ deploy/.env.deploy
21
+ deploy/.env.production
22
+
23
+ # Локальный каталог копий WAL-G (WAL_ARCHIVE_DIR в deploy/.env.local)
24
+ deploy/.data/
25
+
26
+ # Go tool cache pinned via go.mod tool directives; build artifacts only
27
+ *.test
28
+ !deploy/.env.test
29
+ *.out
30
+
31
+ # TypeScript incremental build cache
32
+ *.tsbuildinfo
33
+
34
+ # Python bytecode cache
35
+ __pycache__/
36
+ *.pyc
37
+
38
+ # sdk/conformance
39
+ sdk/conformance/report.json
40
+
41
+ # sdk/python: артефакты `make sdk-release` (wheel, sdist)
42
+ sdk/python/dist/
43
+ .ruff_cache/
44
+ *.egg-info/
45
+
46
+ # reference-bot: токены и ключи экземпляров (.env.example коммитится)
47
+ reference-bot/.env.bot-*
48
+ reference-bot/.env.test-*
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Screw Inbox Ads Platform
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.5
2
+ Name: screwinboxads
3
+ Version: 1.0.0
4
+ Summary: Реклама Screw Inbox Ads в Telegram-боте на aiogram 3: вопрос с кнопками, оффер и подтверждение показов
5
+ Author: Screw Team
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: ads,advertising,aiogram,bot,monetization,sdk,telegram
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Natural Language :: Russian
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Communications :: Chat
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+
27
+ # screwinboxads
28
+
29
+ Реклама Screw Inbox Ads в Telegram-боте на aiogram 3.
30
+
31
+ ## Подключение
32
+
33
+ **1. Установите пакет** (нужен aiogram 3.13 или новее)
34
+
35
+ ```bash
36
+ pip install "screwinboxads<2"
37
+ ```
38
+
39
+ **2. В начало файла, где создаётся `Dispatcher`, добавьте импорт**
40
+
41
+ ```python
42
+ from screwinboxads import ScrewInboxAds
43
+ ```
44
+
45
+ **3. Добавьте сразу после `dp = Dispatcher()`**
46
+
47
+ ```python
48
+ ScrewInboxAds(dp, api_key="ключ-бота") # ключ выдаёт команда Screw Inbox Ads
49
+ ```
50
+
51
+ **4. Покажите рекламу в нужном обработчике — в любом файле бота**
52
+
53
+ ```python
54
+ from screwinboxads import show_ad
55
+
56
+ await show_ad(message) # message — входящее сообщение или нажатие кнопки
57
+ ```
58
+
59
+ Готово. Вопрос, оффер и учёт показов SDK делает сам. Нет рекламы или ошибка — бот работает
60
+ дальше как обычно.
@@ -0,0 +1,34 @@
1
+ # screwinboxads
2
+
3
+ Реклама Screw Inbox Ads в Telegram-боте на aiogram 3.
4
+
5
+ ## Подключение
6
+
7
+ **1. Установите пакет** (нужен aiogram 3.13 или новее)
8
+
9
+ ```bash
10
+ pip install "screwinboxads<2"
11
+ ```
12
+
13
+ **2. В начало файла, где создаётся `Dispatcher`, добавьте импорт**
14
+
15
+ ```python
16
+ from screwinboxads import ScrewInboxAds
17
+ ```
18
+
19
+ **3. Добавьте сразу после `dp = Dispatcher()`**
20
+
21
+ ```python
22
+ ScrewInboxAds(dp, api_key="ключ-бота") # ключ выдаёт команда Screw Inbox Ads
23
+ ```
24
+
25
+ **4. Покажите рекламу в нужном обработчике — в любом файле бота**
26
+
27
+ ```python
28
+ from screwinboxads import show_ad
29
+
30
+ await show_ad(message) # message — входящее сообщение или нажатие кнопки
31
+ ```
32
+
33
+ Готово. Вопрос, оффер и учёт показов SDK делает сам. Нет рекламы или ошибка — бот работает
34
+ дальше как обычно.
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "screwinboxads"
7
+ version = "1.0.0"
8
+ description = "Реклама Screw Inbox Ads в Telegram-боте на aiogram 3: вопрос с кнопками, оффер и подтверждение показов"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Screw Team" }]
13
+ keywords = ["telegram", "aiogram", "bot", "ads", "advertising", "monetization", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Framework :: AsyncIO",
17
+ "Intended Audience :: Developers",
18
+ "Natural Language :: Russian",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Topic :: Communications :: Chat",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ "Typing :: Typed",
30
+ ]
31
+ requires-python = ">=3.10"
32
+ dependencies = []
33
+
34
+ # Зависимости разработки SDK — группа (PEP 735), а не extra: в метаданные пакета не попадает.
35
+ [dependency-groups]
36
+ dev = ["pytest", "pytest-timeout", "pytest-asyncio", "hypothesis", "ruff", "mypy", "build", "aiogram>=3.13,<4"]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/screwinboxads"]
40
+
41
+ # В архив исходников — только то, что нужно для сборки пакета; тесты и кэши остаются в репозитории.
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["src/screwinboxads", "README.md", "LICENSE"]
44
+
45
+ [tool.ruff]
46
+ line-length = 100
47
+ target-version = "py310"
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "I", "UP"]
51
+
52
+ [tool.mypy]
53
+ strict = true
54
+ python_version = "3.10"
55
+
56
+ [tool.pytest.ini_options]
57
+ asyncio_mode = "auto"
58
+ timeout = 120
59
+ markers = [
60
+ "suite: запускает проверочный набор целиком (медленно)",
61
+ "slow: нагрузка в реальном времени (десятки секунд); мутационные тесты её не запускают",
62
+ ]
@@ -0,0 +1,40 @@
1
+ """SDK Screw Inbox Ads: ScrewInboxAds — подключение к aiogram 3 одной строкой
2
+ (`from screwinboxads import ScrewInboxAds`), show_ad — показ рекламы из любого модуля бота,
3
+ ScrewInboxAdsClient — ядро сценария, независимое от фреймворка. `import screwinboxads` работает и
4
+ без aiogram: адаптер загружается при первом обращении к `ScrewInboxAds` или `show_ad`."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from .flow import AdFinished, AdOutcome, ScrewInboxAdsClient, ShowResult
11
+
12
+ if TYPE_CHECKING:
13
+ from ._aiogram3 import ScrewInboxAds, show_ad
14
+
15
+ __all__ = [
16
+ "AdFinished",
17
+ "AdOutcome",
18
+ "ScrewInboxAds",
19
+ "ScrewInboxAdsClient",
20
+ "ShowResult",
21
+ "show_ad",
22
+ ]
23
+
24
+ _ADAPTER_NAMES = frozenset({"ScrewInboxAds", "show_ad"})
25
+
26
+
27
+ def __getattr__(name: str) -> Any:
28
+ if name not in _ADAPTER_NAMES:
29
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
30
+ try:
31
+ from . import _aiogram3
32
+ except ImportError as exc:
33
+ # Только отсутствие самого aiogram получает понятное сообщение; прочие ошибки импорта
34
+ # (например, сломанная зависимость aiogram) не подменяются — иначе причина спрячется.
35
+ if exc.name == "aiogram" or (exc.name or "").startswith("aiogram."):
36
+ raise ImportError(
37
+ "SDK Screw Inbox Ads работает с aiogram: установите aiogram (pip install aiogram)"
38
+ ) from exc
39
+ raise
40
+ return getattr(_aiogram3, name)
@@ -0,0 +1,381 @@
1
+ """Адаптер aiogram 3: `ScrewInboxAds` — `Messenger` на aiogram `Bot` и подключение к диспетчеру
2
+ для нажатий `sa:` (design.md «Пакет», решения 1–3); `show_ad` — показ из любого модуля бота без
3
+ ссылки на экземпляр (change sdk-show-ad-anywhere). Публичные точки входа — `from screwinboxads
4
+ import ScrewInboxAds, show_ad`. Единственное место SDK, которому разрешено зависеть от aiogram
5
+ (openspec/config.yaml)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextvars
11
+ import logging
12
+ import weakref
13
+ from collections.abc import Awaitable, Callable
14
+ from typing import Any
15
+
16
+ from aiogram import Bot, Dispatcher
17
+ from aiogram.exceptions import TelegramAPIError, TelegramNetworkError
18
+ from aiogram.types import (
19
+ CallbackQuery,
20
+ InlineKeyboardButton,
21
+ InlineKeyboardMarkup,
22
+ Message,
23
+ TelegramObject,
24
+ )
25
+
26
+ from .adapter import Messenger, SendResult
27
+ from .flow import DEFAULT_API_URL, AdFinished, ScrewInboxAdsClient, ShowResult
28
+
29
+ __all__ = ["ScrewInboxAds", "show_ad"]
30
+
31
+ logger = logging.getLogger("screwinboxads")
32
+
33
+ # Лимит Telegram: кнопок в одном блоке `buttons` rich message. Часть контракта Bot API,
34
+ # не настройка.
35
+ _RICH_BUTTONS_PER_BLOCK = 8
36
+
37
+ # Ядро хранит `on_ad_finished` как один колбэк на весь клиент (design.md, решение 1), а Bot
38
+ # известен только для текущего callback-запроса; contextvar передаёт его без гонок между
39
+ # конкурентными запросами (обычный mutable-атрибут на инстансе делил бы значение между ними).
40
+ _current_bot: contextvars.ContextVar[Bot | None] = contextvars.ContextVar(
41
+ "screwinboxads_current_bot", default=None
42
+ )
43
+
44
+ # Экземпляр, подключённый к диспетчеру, который сейчас обрабатывает обновление: его ставит внешний
45
+ # middleware `dp.update`, поэтому `show_ad()` из обработчика берёт ключ своего диспетчера, даже если
46
+ # в процессе несколько ботов. Задачи, запущенные из обработчика, наследуют значение.
47
+ _current_ads: contextvars.ContextVar[ScrewInboxAds | None] = contextvars.ContextVar(
48
+ "screwinboxads_current_ads", default=None
49
+ )
50
+ # Живые экземпляры — для `show_ad()` вне обработки обновления (фоновая задача, рассылка).
51
+ _instances: weakref.WeakSet[ScrewInboxAds] = weakref.WeakSet()
52
+
53
+
54
+ def _wrap_on_ad_finished(
55
+ handler: Callable[[Bot, AdFinished], Awaitable[None]] | None,
56
+ ) -> Callable[[AdFinished], Awaitable[None]] | None:
57
+ if handler is None:
58
+ return None
59
+
60
+ async def _handle_finished(event: AdFinished) -> None:
61
+ bot = _current_bot.get()
62
+ if bot is None:
63
+ return
64
+ await handler(bot, event)
65
+
66
+ return _handle_finished
67
+
68
+
69
+ class _AiogramMessenger:
70
+ """`Messenger` (adapter.py) поверх aiogram `Bot`: текст одним сообщением без `parse_mode`;
71
+ вопрос — inline-кнопки по порядку; оффер — одна URL-кнопка, URL не меняется."""
72
+
73
+ def __init__(self, bot: Bot) -> None:
74
+ self._bot = bot
75
+
76
+ async def send_question(
77
+ self, *, chat_id: int, text: str, buttons: tuple[tuple[str, str], ...]
78
+ ) -> SendResult:
79
+ markup = InlineKeyboardMarkup(
80
+ inline_keyboard=[
81
+ [InlineKeyboardButton(text=label, callback_data=data) for label, data in buttons]
82
+ ]
83
+ )
84
+ return await self._call(
85
+ self._bot.send_message(chat_id, text, parse_mode=None, reply_markup=markup)
86
+ )
87
+
88
+ async def send_offer(
89
+ self, *, chat_id: int, text: str, cta_text: str, cta_url: str
90
+ ) -> SendResult:
91
+ markup = InlineKeyboardMarkup(
92
+ inline_keyboard=[[InlineKeyboardButton(text=cta_text, url=cta_url)]]
93
+ )
94
+ return await self._call(
95
+ self._bot.send_message(chat_id, text, parse_mode=None, reply_markup=markup)
96
+ )
97
+
98
+ async def _call(self, call: Awaitable[Any]) -> SendResult:
99
+ """Выполняет вызов Bot API и переводит исход в `SendResult`."""
100
+ try:
101
+ await call
102
+ return SendResult.SENT
103
+ except (TelegramNetworkError, asyncio.TimeoutError) as exc:
104
+ logger.warning("Telegram: результат отправки неизвестен: %s", exc)
105
+ return SendResult.UNKNOWN
106
+ except TelegramAPIError as exc:
107
+ # Текст ошибки Telegram объясняет отказ (например, недопустимый URL кнопки оффера);
108
+ # токена бота в нём нет — aiogram выводит только ответ сервера.
109
+ logger.warning("Telegram отверг сообщение: %s", exc)
110
+ return SendResult.FAILED
111
+
112
+ async def answer_callback(self, *, callback_query_id: str) -> None:
113
+ try:
114
+ await self._bot.answer_callback_query(callback_query_id)
115
+ except TelegramAPIError:
116
+ pass
117
+
118
+
119
+ class _AiogramRichMessenger(_AiogramMessenger):
120
+ """`Messenger` с методами `RichMessenger` (adapter.py): интерактивный креатив одним rich
121
+ message (Bot API 10.3, aiogram 3.31+). Типы rich message импортируются внутри методов: в
122
+ более старом aiogram их нет, и этот класс там не используется (`_messenger_for`)."""
123
+
124
+ async def send_rich_question(
125
+ self, *, chat_id: int, text: str, buttons: tuple[tuple[str, str], ...]
126
+ ) -> SendResult:
127
+ from aiogram.types import (
128
+ InputRichBlockButtons,
129
+ InputRichBlockParagraph,
130
+ InputRichMessage,
131
+ RichMessageButton,
132
+ )
133
+
134
+ blocks: list[Any] = [InputRichBlockParagraph(text=text)]
135
+ for start in range(0, len(buttons), _RICH_BUTTONS_PER_BLOCK):
136
+ chunk = buttons[start : start + _RICH_BUTTONS_PER_BLOCK]
137
+ blocks.append(
138
+ InputRichBlockButtons(
139
+ buttons=[
140
+ RichMessageButton(text=label, callback_data=data) for label, data in chunk
141
+ ],
142
+ )
143
+ )
144
+ return await self._call(
145
+ self._bot.send_rich_message(chat_id, rich_message=InputRichMessage(blocks=blocks))
146
+ )
147
+
148
+ async def edit_rich_message(
149
+ self,
150
+ *,
151
+ chat_id: int,
152
+ message_id: int,
153
+ text: str,
154
+ cta_text: str | None = None,
155
+ cta_url: str | None = None,
156
+ ) -> SendResult:
157
+ from aiogram.types import (
158
+ InputRichBlockButtons,
159
+ InputRichBlockParagraph,
160
+ InputRichMessage,
161
+ RichMessageButton,
162
+ )
163
+
164
+ blocks: list[Any] = [InputRichBlockParagraph(text=text)]
165
+ if cta_text is not None and cta_url is not None:
166
+ blocks.append(
167
+ InputRichBlockButtons(
168
+ buttons=[RichMessageButton(text=cta_text, url=cta_url, style="primary")],
169
+ )
170
+ )
171
+ # Умолчания бота (`parse_mode`, предпросмотр ссылок) к rich message не относятся.
172
+ return await self._call(
173
+ self._bot.edit_message_text(
174
+ chat_id=chat_id,
175
+ message_id=message_id,
176
+ rich_message=InputRichMessage(blocks=blocks),
177
+ parse_mode=None,
178
+ link_preview_options=None,
179
+ disable_web_page_preview=None,
180
+ )
181
+ )
182
+
183
+
184
+ def _messenger_for(bot: Bot) -> Messenger:
185
+ """Адаптер для бота: с rich-методами, если установленный aiogram их поддерживает
186
+ (`send_rich_message` у `Bot`, 3.31+), иначе — только два сообщения (FR-22). Проверка при
187
+ каждом создании, а не при импорте: возможность определяется самим объектом `Bot`."""
188
+ if hasattr(bot, "send_rich_message"):
189
+ return _AiogramRichMessenger(bot)
190
+ return _AiogramMessenger(bot)
191
+
192
+
193
+ class ScrewInboxAds:
194
+ """SDK Screw Inbox Ads для aiogram 3::
195
+
196
+ ads = ScrewInboxAds(dp, api_key="…")
197
+ ...
198
+ await ads.show_ad(message)
199
+
200
+ Конструктор сам подключается к `dp`: внешний middleware диспетчера забирает нажатия с
201
+ `callback_data`, начинающимся с `sa:`, раньше любого обработчика бота (SP-24) — порядок
202
+ регистрации обработчиков значения не имеет. Остальные нажатия идут к обработчикам бота как
203
+ обычно (SP-08). Состояние SDK хранится в памяти процесса, файлов у бота нет."""
204
+
205
+ def __init__(
206
+ self,
207
+ dp: Dispatcher,
208
+ api_key: str,
209
+ *,
210
+ api_url: str = DEFAULT_API_URL,
211
+ timeout: float = 3.0,
212
+ ad_request_timeout: float = 1.0,
213
+ confirm_retry_window: float = 3600.0,
214
+ max_pending_confirms: int = 10_000,
215
+ on_ad_finished: Callable[[Bot, AdFinished], Awaitable[None]] | None = None,
216
+ offer_display_delay: float = 2.0,
217
+ storage_retention_days: int = 7,
218
+ storage_purge_interval: float = 24 * 60 * 60,
219
+ http_workers: int = 16,
220
+ max_pending_shows: int = 16,
221
+ breaker_failures: int = 5,
222
+ breaker_cooldown: float = 10.0,
223
+ ) -> None:
224
+ self._client = ScrewInboxAdsClient(
225
+ api_key=api_key,
226
+ api_url=api_url,
227
+ timeout=timeout,
228
+ ad_request_timeout=ad_request_timeout,
229
+ confirm_retry_window=confirm_retry_window,
230
+ max_pending_confirms=max_pending_confirms,
231
+ on_ad_finished=_wrap_on_ad_finished(on_ad_finished),
232
+ offer_display_delay=offer_display_delay,
233
+ storage_retention_days=storage_retention_days,
234
+ storage_purge_interval=storage_purge_interval,
235
+ http_workers=http_workers,
236
+ max_pending_shows=max_pending_shows,
237
+ breaker_failures=breaker_failures,
238
+ breaker_cooldown=breaker_cooldown,
239
+ )
240
+ dp.update.outer_middleware(self._update_middleware)
241
+ dp.callback_query.outer_middleware(self._middleware)
242
+ _instances.add(self)
243
+
244
+ async def _update_middleware(
245
+ self,
246
+ handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
247
+ event: TelegramObject,
248
+ data: dict[str, Any],
249
+ ) -> Any:
250
+ token = _current_ads.set(self)
251
+ try:
252
+ return await handler(event, data)
253
+ finally:
254
+ _current_ads.reset(token)
255
+
256
+ async def _middleware(
257
+ self,
258
+ handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
259
+ event: TelegramObject,
260
+ data: dict[str, Any],
261
+ ) -> Any:
262
+ if isinstance(event, CallbackQuery) and (event.data or "").startswith("sa:"):
263
+ await self._handle_callback(event)
264
+ return None
265
+ return await handler(event, data)
266
+
267
+ async def _handle_callback(self, callback_query: CallbackQuery) -> None:
268
+ bot = callback_query.bot
269
+ message = callback_query.message
270
+ if bot is None or message is None:
271
+ missing = "бота" if bot is None else "сообщения с кнопкой"
272
+ logger.warning("нажатие sa: не обработано: нет %s", missing)
273
+ return
274
+ messenger = _messenger_for(bot)
275
+ token = _current_bot.set(bot)
276
+ try:
277
+ await self._client.handle_callback(
278
+ messenger,
279
+ callback_data=callback_query.data or "",
280
+ callback_query_id=callback_query.id,
281
+ chat_id=message.chat.id,
282
+ user_id=str(callback_query.from_user.id),
283
+ # есть и у `InaccessibleMessage` (старое сообщение) — изменить его можно
284
+ message_id=message.message_id,
285
+ )
286
+ finally:
287
+ _current_bot.reset(token)
288
+
289
+ async def show_ad(
290
+ self,
291
+ event: Message | CallbackQuery | None = None,
292
+ *,
293
+ bot: Bot | None = None,
294
+ chat_id: int | None = None,
295
+ user_id: int | None = None,
296
+ ) -> ShowResult:
297
+ """Показывает рекламу пользователю события: бот, чат и пользователь берутся из
298
+ `Message` или `CallbackQuery` (у нажатия — пользователь нажавшего и чат сообщения под
299
+ кнопкой). Явные `bot`, `chat_id`, `user_id` — для показа без события; переданные вместе с
300
+ событием не используются, событие главнее. Возвращает управление сразу, показ идёт в
301
+ фоне: `QUEUED` — принят, `SKIPPED` — пропущен (предел показов или пауза предохранителя).
302
+ Не хватает данных или неизвестный тип события — `FAILED` и запись в журнал, исключение
303
+ в код бота не выходит (SP-21, SP-02, SP-31)."""
304
+ try:
305
+ target = _resolve_target(event, bot, chat_id, user_id)
306
+ except Exception:
307
+ logger.warning("show_ad: не удалось разобрать событие", exc_info=True)
308
+ return ShowResult.FAILED
309
+ if target is None:
310
+ return ShowResult.FAILED
311
+ target_bot, target_chat_id, target_user_id = target
312
+ return await self._client.show_ad(
313
+ _messenger_for(target_bot), chat_id=target_chat_id, user_id=str(target_user_id)
314
+ )
315
+
316
+ async def wait_shows(self) -> None:
317
+ """Дожидается начатых фоновых показов: при остановке бота и в тестах."""
318
+ await self._client.wait_shows()
319
+
320
+ async def close_ad(self, user_id: int, reason: str) -> None:
321
+ await self._client.close_ad(str(user_id), reason)
322
+
323
+
324
+ async def show_ad(
325
+ event: Message | CallbackQuery | None = None,
326
+ *,
327
+ bot: Bot | None = None,
328
+ chat_id: int | None = None,
329
+ user_id: int | None = None,
330
+ ) -> ShowResult:
331
+ """`ScrewInboxAds.show_ad` без ссылки на экземпляр — для обработчиков в любом модуле бота.
332
+ Внутри обработки обновления — экземпляр диспетчера этого обновления; вне её — единственный
333
+ созданный экземпляр. SDK не подключён или экземпляров несколько вне обработки — `FAILED` и
334
+ запись `ERROR` в журнал: угадывать ключ нельзя, показ ушёл бы не тому боту."""
335
+ ads = _current_ads.get()
336
+ if ads is None:
337
+ alive = list(_instances)
338
+ if len(alive) != 1:
339
+ if alive:
340
+ logger.error(
341
+ "show_ad: подключено %d экземпляров ScrewInboxAds, а вызов не из обработчика "
342
+ "обновления — вызовите show_ad у нужного экземпляра",
343
+ len(alive),
344
+ )
345
+ else:
346
+ logger.error(
347
+ "show_ad: SDK не подключён — создайте ScrewInboxAds(dp, api_key=...) "
348
+ "сразу после создания Dispatcher"
349
+ )
350
+ return ShowResult.FAILED
351
+ ads = alive[0]
352
+ return await ads.show_ad(event, bot=bot, chat_id=chat_id, user_id=user_id)
353
+
354
+
355
+ def _resolve_target(
356
+ event: Message | CallbackQuery | None,
357
+ bot: Bot | None,
358
+ chat_id: int | None,
359
+ user_id: int | None,
360
+ ) -> tuple[Bot, int, int] | None:
361
+ """Бот, чат и пользователь показа; `None` (с записью в журнал) — если чего-то не хватает."""
362
+ if isinstance(event, Message):
363
+ bot = event.bot
364
+ chat_id = event.chat.id
365
+ user_id = event.from_user.id if event.from_user is not None else None
366
+ elif isinstance(event, CallbackQuery):
367
+ bot = event.bot
368
+ chat_id = event.message.chat.id if event.message is not None else None
369
+ user_id = event.from_user.id
370
+ elif event is not None:
371
+ logger.warning("show_ad: неподдерживаемый тип события %s", type(event).__name__)
372
+ return None
373
+ missing = [
374
+ name
375
+ for name, value in (("bot", bot), ("chat_id", chat_id), ("user_id", user_id))
376
+ if value is None
377
+ ]
378
+ if bot is None or chat_id is None or user_id is None:
379
+ logger.warning("show_ad: не хватает данных для показа: %s", ", ".join(missing))
380
+ return None
381
+ return bot, chat_id, user_id