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.
- screwinboxads-1.0.0/.gitignore +48 -0
- screwinboxads-1.0.0/LICENSE +21 -0
- screwinboxads-1.0.0/PKG-INFO +60 -0
- screwinboxads-1.0.0/README.md +34 -0
- screwinboxads-1.0.0/pyproject.toml +62 -0
- screwinboxads-1.0.0/src/screwinboxads/__init__.py +40 -0
- screwinboxads-1.0.0/src/screwinboxads/_aiogram3.py +381 -0
- screwinboxads-1.0.0/src/screwinboxads/_http.py +313 -0
- screwinboxads-1.0.0/src/screwinboxads/_resilience.py +172 -0
- screwinboxads-1.0.0/src/screwinboxads/adapter.py +78 -0
- screwinboxads-1.0.0/src/screwinboxads/flow.py +726 -0
- screwinboxads-1.0.0/src/screwinboxads/py.typed +0 -0
- screwinboxads-1.0.0/src/screwinboxads/storage/__init__.py +108 -0
- screwinboxads-1.0.0/src/screwinboxads/storage/memory.py +97 -0
|
@@ -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
|