easyyad 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.
- easyyad-1.0.0/LICENSE +21 -0
- easyyad-1.0.0/PKG-INFO +172 -0
- easyyad-1.0.0/README.md +147 -0
- easyyad-1.0.0/easyyad/__init__.py +23 -0
- easyyad-1.0.0/easyyad/client.py +261 -0
- easyyad-1.0.0/easyyad/py.typed +0 -0
- easyyad-1.0.0/easyyad.egg-info/PKG-INFO +172 -0
- easyyad-1.0.0/easyyad.egg-info/SOURCES.txt +12 -0
- easyyad-1.0.0/easyyad.egg-info/dependency_links.txt +1 -0
- easyyad-1.0.0/easyyad.egg-info/requires.txt +3 -0
- easyyad-1.0.0/easyyad.egg-info/top_level.txt +1 -0
- easyyad-1.0.0/pyproject.toml +38 -0
- easyyad-1.0.0/setup.cfg +4 -0
- easyyad-1.0.0/tests/test_client.py +217 -0
easyyad-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 N'GUESSAN JEAN STEPHANE ELIEL (EasyAd)
|
|
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.
|
easyyad-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easyyad
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Show EasyAd ads in your Telegram bot and earn 50% of what advertisers pay.
|
|
5
|
+
Author-email: EasyAd <support@easyyad.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://easyyad.com
|
|
8
|
+
Project-URL: Documentation, https://easyyad.com/docs
|
|
9
|
+
Keywords: telegram,telegram-bot,bot,ads,advertising,monetization,aiogram,easyad
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Classifier: Topic :: Communications :: Chat
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Provides-Extra: async
|
|
23
|
+
Requires-Dist: httpx>=0.27; extra == "async"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# easyyad (Python)
|
|
27
|
+
|
|
28
|
+
> Le paquet s'appelle **easyyad**, comme le domaine : `easyad` était déjà pris sur PyPI par une
|
|
29
|
+
> bibliothèque Active Directory sans rapport avec nous.
|
|
30
|
+
|
|
31
|
+
Affichez des publicités EasyAd dans votre bot Telegram et touchez **50 %** de ce que l'annonceur paie.
|
|
32
|
+
|
|
33
|
+
> **English** — Show EasyAd ads in your Telegram bot and earn **50%** of what advertisers pay.
|
|
34
|
+
> `pip install easyyad` (or `easyyad[async]` for aiogram), create `EasyAd(api_key=...)`, then call
|
|
35
|
+
> `next_ad(...)` and `mark_shown(...)` after your bot has answered. Python 3.9+, no required dependency.
|
|
36
|
+
> Full documentation: https://easyyad.com/docs
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install easyyad # client synchrone, sans dépendance
|
|
40
|
+
pip install easyyad[async] # ajoute httpx pour la version asynchrone
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## En trois lignes
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import os
|
|
47
|
+
from easyyad import EasyAd
|
|
48
|
+
|
|
49
|
+
easyad = EasyAd(api_key=os.environ["EASYAD_API_KEY"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Récupérez votre clé dans votre espace EasyAd, onglet **Bots** → votre bot → **Clé d'intégration**.
|
|
53
|
+
Elle n'est affichée qu'une fois : nous n'en gardons qu'une empreinte, nous ne pouvons pas vous la
|
|
54
|
+
redonner, seulement la remplacer.
|
|
55
|
+
|
|
56
|
+
## Montrer une annonce
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
ad = easyad.next_ad(user_id=message.from_user.id, locale=message.from_user.language_code)
|
|
60
|
+
|
|
61
|
+
if ad:
|
|
62
|
+
bot.send_message(
|
|
63
|
+
message.chat.id, ad.text,
|
|
64
|
+
parse_mode="HTML", reply_markup=ad.reply_markup,
|
|
65
|
+
)
|
|
66
|
+
easyad.mark_shown(ad.placement_id) # sans cette ligne, vous n'êtes pas payé
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`next_ad` renvoie `None` quand il n'y a rien à montrer, quand cet utilisateur a déjà vu son quota du
|
|
70
|
+
jour, ou si EasyAd est momentanément injoignable. Dans les trois cas, votre bot continue comme si
|
|
71
|
+
rien ne s'était passé : **une panne chez nous ne doit jamais casser votre bot.**
|
|
72
|
+
|
|
73
|
+
### Le raccourci `serve()`
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
easyad.serve(
|
|
77
|
+
user_id=message.from_user.id,
|
|
78
|
+
locale=message.from_user.language_code,
|
|
79
|
+
send=lambda ad: bot.send_message(
|
|
80
|
+
message.chat.id, ad.text, parse_mode="HTML", reply_markup=ad.reply_markup,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Demande, envoie, confirme. Si votre fonction `send` lève une exception, rien n'est confirmé — donc
|
|
86
|
+
rien ne vous est compté, et l'annonceur n'est pas facturé.
|
|
87
|
+
|
|
88
|
+
## Bots asynchrones (aiogram, python-telegram-bot)
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from easyyad import AsyncEasyAd
|
|
92
|
+
|
|
93
|
+
easyad = AsyncEasyAd(api_key=os.environ["EASYAD_API_KEY"])
|
|
94
|
+
|
|
95
|
+
@dp.message()
|
|
96
|
+
async def handler(message: Message):
|
|
97
|
+
await repondre_normalement(message)
|
|
98
|
+
|
|
99
|
+
ad = await easyad.next_ad(user_id=message.from_user.id, locale=message.from_user.language_code)
|
|
100
|
+
if ad:
|
|
101
|
+
await message.answer(ad.text, parse_mode="HTML", reply_markup=ad.reply_markup)
|
|
102
|
+
await easyad.mark_shown(ad.placement_id)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`AsyncEasyAd` utilise `httpx` s'il est installé, sinon `aiohttp`. Si aucune des deux n'est présente,
|
|
106
|
+
l'erreur arrive à la construction du client — pas au premier appel en production.
|
|
107
|
+
|
|
108
|
+
## Avec une image
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
if ad.image_url:
|
|
112
|
+
bot.send_photo(chat_id, ad.image_url, caption=ad.text, parse_mode="HTML", reply_markup=ad.reply_markup)
|
|
113
|
+
else:
|
|
114
|
+
bot.send_message(chat_id, ad.text, parse_mode="HTML", reply_markup=ad.reply_markup)
|
|
115
|
+
|
|
116
|
+
easyad.mark_shown(ad.placement_id)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Vérifier l'intégration
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
print(easyad.health())
|
|
123
|
+
# {'ok': True, 'bot': {'name': 'Mon bot', 'status': 'approved', 'serving': True}, 'limits': {...}}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Appelez-le au démarrage : vous saurez tout de suite si la clé est bonne et si votre bot est approuvé.
|
|
127
|
+
|
|
128
|
+
## Ce que l'on reçoit de vous
|
|
129
|
+
|
|
130
|
+
`user_id` est **haché avant l'envoi** (SHA-256, tronqué à 32 caractères, salé avec votre clé). Nous ne
|
|
131
|
+
voyons jamais l'identifiant Telegram de vos utilisateurs, et comme le sel est votre clé, deux
|
|
132
|
+
partenaires ne peuvent pas recouper leurs audiences. Cette empreinte ne sert qu'à une chose : ne pas
|
|
133
|
+
montrer trop d'annonces à la même personne.
|
|
134
|
+
|
|
135
|
+
Vous pouvez choisir votre propre sel avec `user_salt=` — gardez-le stable, sinon le plafond de
|
|
136
|
+
fréquence repart de zéro à chaque changement.
|
|
137
|
+
|
|
138
|
+
## Options
|
|
139
|
+
|
|
140
|
+
| Option | Défaut | À quoi ça sert |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `api_key` | — | Votre clé secrète. Obligatoire. |
|
|
143
|
+
| `base_url` | `https://api.easyyad.com/api/v1/sdk/bot` | Pour viser un environnement de test. |
|
|
144
|
+
| `timeout` | `4.0` | Secondes. Au-delà, on renonce : votre bot doit rester rapide. |
|
|
145
|
+
| `raise_on_error` | `False` | `True` pour gérer les exceptions vous-même. |
|
|
146
|
+
| `user_salt` | la clé | Sel du hachage des identifiants. |
|
|
147
|
+
|
|
148
|
+
La journalisation passe par le logger standard `easyyad` :
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
import logging
|
|
152
|
+
logging.getLogger("easyyad").setLevel(logging.DEBUG)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Fréquence
|
|
156
|
+
|
|
157
|
+
Deux limites, réglables dans votre espace EasyAd :
|
|
158
|
+
|
|
159
|
+
- **annonces par utilisateur et par jour** (3 par défaut) ;
|
|
160
|
+
- **délai minimum entre deux annonces** pour la même personne (60 minutes par défaut).
|
|
161
|
+
|
|
162
|
+
Elles existent pour protéger votre audience : une conversation privée saturée de publicité fait
|
|
163
|
+
partir vos utilisateurs, et c'est votre revenu qui disparaît avec eux. Vous pouvez les resserrer
|
|
164
|
+
autant que vous voulez.
|
|
165
|
+
|
|
166
|
+
## Rémunération
|
|
167
|
+
|
|
168
|
+
Vous êtes payé sur les annonces **confirmées**. Une annonce demandée mais jamais confirmée est
|
|
169
|
+
abandonnée au bout de 30 minutes et ne compte pour personne. Les gains apparaissent dans votre
|
|
170
|
+
portefeuille après la période de vérification, puis deviennent retirables.
|
|
171
|
+
|
|
172
|
+
Documentation complète : <https://www.easyyad.com/docs/bots>
|
easyyad-1.0.0/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# easyyad (Python)
|
|
2
|
+
|
|
3
|
+
> Le paquet s'appelle **easyyad**, comme le domaine : `easyad` était déjà pris sur PyPI par une
|
|
4
|
+
> bibliothèque Active Directory sans rapport avec nous.
|
|
5
|
+
|
|
6
|
+
Affichez des publicités EasyAd dans votre bot Telegram et touchez **50 %** de ce que l'annonceur paie.
|
|
7
|
+
|
|
8
|
+
> **English** — Show EasyAd ads in your Telegram bot and earn **50%** of what advertisers pay.
|
|
9
|
+
> `pip install easyyad` (or `easyyad[async]` for aiogram), create `EasyAd(api_key=...)`, then call
|
|
10
|
+
> `next_ad(...)` and `mark_shown(...)` after your bot has answered. Python 3.9+, no required dependency.
|
|
11
|
+
> Full documentation: https://easyyad.com/docs
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install easyyad # client synchrone, sans dépendance
|
|
15
|
+
pip install easyyad[async] # ajoute httpx pour la version asynchrone
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## En trois lignes
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import os
|
|
22
|
+
from easyyad import EasyAd
|
|
23
|
+
|
|
24
|
+
easyad = EasyAd(api_key=os.environ["EASYAD_API_KEY"])
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Récupérez votre clé dans votre espace EasyAd, onglet **Bots** → votre bot → **Clé d'intégration**.
|
|
28
|
+
Elle n'est affichée qu'une fois : nous n'en gardons qu'une empreinte, nous ne pouvons pas vous la
|
|
29
|
+
redonner, seulement la remplacer.
|
|
30
|
+
|
|
31
|
+
## Montrer une annonce
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
ad = easyad.next_ad(user_id=message.from_user.id, locale=message.from_user.language_code)
|
|
35
|
+
|
|
36
|
+
if ad:
|
|
37
|
+
bot.send_message(
|
|
38
|
+
message.chat.id, ad.text,
|
|
39
|
+
parse_mode="HTML", reply_markup=ad.reply_markup,
|
|
40
|
+
)
|
|
41
|
+
easyad.mark_shown(ad.placement_id) # sans cette ligne, vous n'êtes pas payé
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`next_ad` renvoie `None` quand il n'y a rien à montrer, quand cet utilisateur a déjà vu son quota du
|
|
45
|
+
jour, ou si EasyAd est momentanément injoignable. Dans les trois cas, votre bot continue comme si
|
|
46
|
+
rien ne s'était passé : **une panne chez nous ne doit jamais casser votre bot.**
|
|
47
|
+
|
|
48
|
+
### Le raccourci `serve()`
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
easyad.serve(
|
|
52
|
+
user_id=message.from_user.id,
|
|
53
|
+
locale=message.from_user.language_code,
|
|
54
|
+
send=lambda ad: bot.send_message(
|
|
55
|
+
message.chat.id, ad.text, parse_mode="HTML", reply_markup=ad.reply_markup,
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Demande, envoie, confirme. Si votre fonction `send` lève une exception, rien n'est confirmé — donc
|
|
61
|
+
rien ne vous est compté, et l'annonceur n'est pas facturé.
|
|
62
|
+
|
|
63
|
+
## Bots asynchrones (aiogram, python-telegram-bot)
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from easyyad import AsyncEasyAd
|
|
67
|
+
|
|
68
|
+
easyad = AsyncEasyAd(api_key=os.environ["EASYAD_API_KEY"])
|
|
69
|
+
|
|
70
|
+
@dp.message()
|
|
71
|
+
async def handler(message: Message):
|
|
72
|
+
await repondre_normalement(message)
|
|
73
|
+
|
|
74
|
+
ad = await easyad.next_ad(user_id=message.from_user.id, locale=message.from_user.language_code)
|
|
75
|
+
if ad:
|
|
76
|
+
await message.answer(ad.text, parse_mode="HTML", reply_markup=ad.reply_markup)
|
|
77
|
+
await easyad.mark_shown(ad.placement_id)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`AsyncEasyAd` utilise `httpx` s'il est installé, sinon `aiohttp`. Si aucune des deux n'est présente,
|
|
81
|
+
l'erreur arrive à la construction du client — pas au premier appel en production.
|
|
82
|
+
|
|
83
|
+
## Avec une image
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
if ad.image_url:
|
|
87
|
+
bot.send_photo(chat_id, ad.image_url, caption=ad.text, parse_mode="HTML", reply_markup=ad.reply_markup)
|
|
88
|
+
else:
|
|
89
|
+
bot.send_message(chat_id, ad.text, parse_mode="HTML", reply_markup=ad.reply_markup)
|
|
90
|
+
|
|
91
|
+
easyad.mark_shown(ad.placement_id)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Vérifier l'intégration
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
print(easyad.health())
|
|
98
|
+
# {'ok': True, 'bot': {'name': 'Mon bot', 'status': 'approved', 'serving': True}, 'limits': {...}}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Appelez-le au démarrage : vous saurez tout de suite si la clé est bonne et si votre bot est approuvé.
|
|
102
|
+
|
|
103
|
+
## Ce que l'on reçoit de vous
|
|
104
|
+
|
|
105
|
+
`user_id` est **haché avant l'envoi** (SHA-256, tronqué à 32 caractères, salé avec votre clé). Nous ne
|
|
106
|
+
voyons jamais l'identifiant Telegram de vos utilisateurs, et comme le sel est votre clé, deux
|
|
107
|
+
partenaires ne peuvent pas recouper leurs audiences. Cette empreinte ne sert qu'à une chose : ne pas
|
|
108
|
+
montrer trop d'annonces à la même personne.
|
|
109
|
+
|
|
110
|
+
Vous pouvez choisir votre propre sel avec `user_salt=` — gardez-le stable, sinon le plafond de
|
|
111
|
+
fréquence repart de zéro à chaque changement.
|
|
112
|
+
|
|
113
|
+
## Options
|
|
114
|
+
|
|
115
|
+
| Option | Défaut | À quoi ça sert |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `api_key` | — | Votre clé secrète. Obligatoire. |
|
|
118
|
+
| `base_url` | `https://api.easyyad.com/api/v1/sdk/bot` | Pour viser un environnement de test. |
|
|
119
|
+
| `timeout` | `4.0` | Secondes. Au-delà, on renonce : votre bot doit rester rapide. |
|
|
120
|
+
| `raise_on_error` | `False` | `True` pour gérer les exceptions vous-même. |
|
|
121
|
+
| `user_salt` | la clé | Sel du hachage des identifiants. |
|
|
122
|
+
|
|
123
|
+
La journalisation passe par le logger standard `easyyad` :
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import logging
|
|
127
|
+
logging.getLogger("easyyad").setLevel(logging.DEBUG)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Fréquence
|
|
131
|
+
|
|
132
|
+
Deux limites, réglables dans votre espace EasyAd :
|
|
133
|
+
|
|
134
|
+
- **annonces par utilisateur et par jour** (3 par défaut) ;
|
|
135
|
+
- **délai minimum entre deux annonces** pour la même personne (60 minutes par défaut).
|
|
136
|
+
|
|
137
|
+
Elles existent pour protéger votre audience : une conversation privée saturée de publicité fait
|
|
138
|
+
partir vos utilisateurs, et c'est votre revenu qui disparaît avec eux. Vous pouvez les resserrer
|
|
139
|
+
autant que vous voulez.
|
|
140
|
+
|
|
141
|
+
## Rémunération
|
|
142
|
+
|
|
143
|
+
Vous êtes payé sur les annonces **confirmées**. Une annonce demandée mais jamais confirmée est
|
|
144
|
+
abandonnée au bout de 30 minutes et ne compte pour personne. Les gains apparaissent dans votre
|
|
145
|
+
portefeuille après la période de vérification, puis deviennent retirables.
|
|
146
|
+
|
|
147
|
+
Documentation complète : <https://www.easyyad.com/docs/bots>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SDK EasyAd pour les bots — Python 3.9+.
|
|
3
|
+
|
|
4
|
+
Deux appels suffisent : demander une annonce, puis confirmer qu'elle a bien été envoyée.
|
|
5
|
+
Par défaut, rien ne lève d'exception : un réseau coupé ou un quota atteint renvoie simplement
|
|
6
|
+
``None``, pour qu'une panne chez nous ne casse jamais votre bot.
|
|
7
|
+
|
|
8
|
+
from easyyad import EasyAd
|
|
9
|
+
|
|
10
|
+
easyad = EasyAd(api_key=os.environ["EASYAD_API_KEY"])
|
|
11
|
+
|
|
12
|
+
ad = easyad.next_ad(user_id=message.from_user.id, locale=message.from_user.language_code)
|
|
13
|
+
if ad:
|
|
14
|
+
await bot.send_message(chat_id, ad.text, parse_mode="HTML", reply_markup=ad.reply_markup)
|
|
15
|
+
easyad.mark_shown(ad.placement_id)
|
|
16
|
+
|
|
17
|
+
Une variante asynchrone (``AsyncEasyAd``) est disponible pour aiogram et les bots async.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .client import AsyncEasyAd, EasyAd, EasyAdError, Ad, hash_user_id
|
|
21
|
+
|
|
22
|
+
__all__ = ["EasyAd", "AsyncEasyAd", "EasyAdError", "Ad", "hash_user_id"]
|
|
23
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Client HTTP du SDK EasyAd : version synchrone et version asynchrone."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Awaitable, Callable, Dict, Optional
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE = "https://api.easyyad.com/api/v1/sdk/bot"
|
|
15
|
+
LOGGER = logging.getLogger("easyyad")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EasyAdError(Exception):
|
|
19
|
+
"""Erreur renvoyée par EasyAd, ou impossibilité de le joindre."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, *, status: int = 0, code: str = "unknown",
|
|
22
|
+
retry_after_seconds: Optional[int] = None) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.status = status
|
|
25
|
+
self.code = code
|
|
26
|
+
self.retry_after_seconds = retry_after_seconds
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Ad:
|
|
31
|
+
"""Une annonce prête à envoyer."""
|
|
32
|
+
|
|
33
|
+
placement_id: str
|
|
34
|
+
text: str
|
|
35
|
+
reply_markup: Dict[str, Any]
|
|
36
|
+
parse_mode: str = "HTML"
|
|
37
|
+
image_url: Optional[str] = None
|
|
38
|
+
label: str = "#Ad #EasyAd"
|
|
39
|
+
confirm_within_seconds: int = 1800
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_payload(cls, payload: Dict[str, Any]) -> "Ad":
|
|
43
|
+
return cls(
|
|
44
|
+
placement_id=payload["placement_id"],
|
|
45
|
+
text=payload["text"],
|
|
46
|
+
reply_markup=payload.get("reply_markup") or {},
|
|
47
|
+
parse_mode=payload.get("parse_mode") or "HTML",
|
|
48
|
+
image_url=payload.get("image_url"),
|
|
49
|
+
label=payload.get("label") or "#Ad #EasyAd",
|
|
50
|
+
confirm_within_seconds=int(payload.get("confirm_within_seconds") or 1800),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def hash_user_id(user_id: Any, salt: str) -> str:
|
|
55
|
+
"""Empreinte stable d'un identifiant : nous n'avons jamais besoin de l'identifiant réel."""
|
|
56
|
+
digest = hashlib.sha256(f"{salt}:{user_id}".encode("utf-8")).hexdigest()
|
|
57
|
+
return digest[:32]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _Base:
|
|
61
|
+
"""Réglages et traitement des réponses, partagés par les deux clients."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
api_key: str,
|
|
66
|
+
*,
|
|
67
|
+
base_url: str = DEFAULT_BASE,
|
|
68
|
+
timeout: float = 4.0,
|
|
69
|
+
raise_on_error: bool = False,
|
|
70
|
+
user_salt: Optional[str] = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
if not api_key:
|
|
73
|
+
raise EasyAdError("Clé EasyAd manquante : passez api_key=…", code="missing_api_key")
|
|
74
|
+
self.api_key = api_key
|
|
75
|
+
self.base_url = base_url.rstrip("/")
|
|
76
|
+
self.timeout = timeout
|
|
77
|
+
self.raise_on_error = raise_on_error
|
|
78
|
+
self.user_salt = user_salt or api_key
|
|
79
|
+
|
|
80
|
+
# -- préparation ----------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _headers(self, with_body: bool) -> Dict[str, str]:
|
|
83
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json"}
|
|
84
|
+
if with_body:
|
|
85
|
+
headers["Content-Type"] = "application/json"
|
|
86
|
+
return headers
|
|
87
|
+
|
|
88
|
+
def _next_payload(self, user_id: Any, locale: Optional[str]) -> Dict[str, Any]:
|
|
89
|
+
if user_id is None or user_id == "":
|
|
90
|
+
raise EasyAdError(
|
|
91
|
+
"user_id est requis : il sert à limiter la fréquence par utilisateur.",
|
|
92
|
+
code="missing_user_id",
|
|
93
|
+
)
|
|
94
|
+
payload: Dict[str, Any] = {"user_ref": hash_user_id(user_id, self.user_salt)}
|
|
95
|
+
if locale:
|
|
96
|
+
payload["locale"] = locale
|
|
97
|
+
return payload
|
|
98
|
+
|
|
99
|
+
# -- traitement des réponses ----------------------------------------
|
|
100
|
+
|
|
101
|
+
def _handle(self, status: int, text: str) -> Optional[Dict[str, Any]]:
|
|
102
|
+
body: Optional[Dict[str, Any]]
|
|
103
|
+
try:
|
|
104
|
+
body = json.loads(text) if text else None
|
|
105
|
+
except ValueError:
|
|
106
|
+
body = None
|
|
107
|
+
|
|
108
|
+
if status >= 400:
|
|
109
|
+
code = (body or {}).get("error") or f"http_{status}"
|
|
110
|
+
message = (body or {}).get("message") or f"EasyAd a répondu {status}."
|
|
111
|
+
error = EasyAdError(
|
|
112
|
+
message, status=status, code=code,
|
|
113
|
+
retry_after_seconds=(body or {}).get("retry_after_seconds"),
|
|
114
|
+
)
|
|
115
|
+
# Un quota atteint est une réponse normale, pas une panne.
|
|
116
|
+
if code == "frequency_capped":
|
|
117
|
+
LOGGER.debug("EasyAd : quota de fréquence atteint pour cet utilisateur.")
|
|
118
|
+
return None
|
|
119
|
+
if self.raise_on_error:
|
|
120
|
+
raise error
|
|
121
|
+
LOGGER.warning("EasyAd : %s (%s)", message, code)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
return body
|
|
125
|
+
|
|
126
|
+
def _failure(self, exc: BaseException) -> None:
|
|
127
|
+
timeout = isinstance(exc, (TimeoutError, urllib.error.URLError)) and "timed out" in str(exc).lower()
|
|
128
|
+
error = EasyAdError(
|
|
129
|
+
f"EasyAd n'a pas répondu en {self.timeout} s." if timeout else f"EasyAd est injoignable : {exc}",
|
|
130
|
+
code="timeout" if timeout else "network_error",
|
|
131
|
+
)
|
|
132
|
+
if self.raise_on_error:
|
|
133
|
+
raise error
|
|
134
|
+
LOGGER.warning("%s", error)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _ad_from(body: Optional[Dict[str, Any]]) -> Optional[Ad]:
|
|
139
|
+
if not body or not body.get("ad"):
|
|
140
|
+
return None
|
|
141
|
+
return Ad.from_payload(body["ad"])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class EasyAd(_Base):
|
|
145
|
+
"""Client synchrone, sans dépendance : il n'utilise que la bibliothèque standard."""
|
|
146
|
+
|
|
147
|
+
def next_ad(self, *, user_id: Any, locale: Optional[str] = None) -> Optional[Ad]:
|
|
148
|
+
"""Une annonce à montrer, ou ``None`` : rien à montrer, quota atteint, ou souci réseau."""
|
|
149
|
+
return self._ad_from(self._request("POST", "/ads/next", self._next_payload(user_id, locale)))
|
|
150
|
+
|
|
151
|
+
def mark_shown(self, placement_id: str) -> bool:
|
|
152
|
+
"""À appeler **après** l'envoi : c'est cette confirmation qui déclenche votre rémunération."""
|
|
153
|
+
if not placement_id:
|
|
154
|
+
return False
|
|
155
|
+
quoted = urllib.parse.quote(placement_id, safe="")
|
|
156
|
+
body = self._request("POST", f"/ads/{quoted}/shown", {})
|
|
157
|
+
return bool(body and body.get("status") == "published")
|
|
158
|
+
|
|
159
|
+
def health(self) -> Optional[Dict[str, Any]]:
|
|
160
|
+
"""Vérifie la clé et renvoie les réglages du bot. Pratique au démarrage."""
|
|
161
|
+
return self._request("GET", "/health", None)
|
|
162
|
+
|
|
163
|
+
def serve(self, *, user_id: Any, send: Callable[[Ad], Any], locale: Optional[str] = None) -> bool:
|
|
164
|
+
"""
|
|
165
|
+
Raccourci : demande, envoie via ``send``, confirme.
|
|
166
|
+
|
|
167
|
+
Si ``send`` lève une exception, rien n'est confirmé — donc rien n'est facturé ni payé.
|
|
168
|
+
"""
|
|
169
|
+
ad = self.next_ad(user_id=user_id, locale=locale)
|
|
170
|
+
if ad is None:
|
|
171
|
+
return False
|
|
172
|
+
send(ad)
|
|
173
|
+
return self.mark_shown(ad.placement_id)
|
|
174
|
+
|
|
175
|
+
def _request(self, method: str, path: str, payload: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
176
|
+
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
177
|
+
request = urllib.request.Request(
|
|
178
|
+
self.base_url + path, data=data, method=method, headers=self._headers(payload is not None)
|
|
179
|
+
)
|
|
180
|
+
try:
|
|
181
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
182
|
+
return self._handle(response.status, response.read().decode("utf-8"))
|
|
183
|
+
except urllib.error.HTTPError as exc: # réponse reçue, mais code d'erreur
|
|
184
|
+
return self._handle(exc.code, exc.read().decode("utf-8", errors="replace"))
|
|
185
|
+
except EasyAdError:
|
|
186
|
+
raise
|
|
187
|
+
except Exception as exc: # réseau, DNS, TLS, délai dépassé
|
|
188
|
+
return self._failure(exc)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class AsyncEasyAd(_Base):
|
|
192
|
+
"""
|
|
193
|
+
Client asynchrone pour aiogram, python-telegram-bot et autres bots async.
|
|
194
|
+
|
|
195
|
+
Nécessite ``httpx`` ou ``aiohttp``. Si aucune des deux n'est installée, l'erreur le dit
|
|
196
|
+
clairement au démarrage plutôt qu'au premier appel en production.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
200
|
+
super().__init__(*args, **kwargs)
|
|
201
|
+
self._client_factory = self._detect_backend()
|
|
202
|
+
|
|
203
|
+
async def next_ad(self, *, user_id: Any, locale: Optional[str] = None) -> Optional[Ad]:
|
|
204
|
+
return self._ad_from(await self._request("POST", "/ads/next", self._next_payload(user_id, locale)))
|
|
205
|
+
|
|
206
|
+
async def mark_shown(self, placement_id: str) -> bool:
|
|
207
|
+
if not placement_id:
|
|
208
|
+
return False
|
|
209
|
+
quoted = urllib.parse.quote(placement_id, safe="")
|
|
210
|
+
body = await self._request("POST", f"/ads/{quoted}/shown", {})
|
|
211
|
+
return bool(body and body.get("status") == "published")
|
|
212
|
+
|
|
213
|
+
async def health(self) -> Optional[Dict[str, Any]]:
|
|
214
|
+
return await self._request("GET", "/health", None)
|
|
215
|
+
|
|
216
|
+
async def serve(
|
|
217
|
+
self, *, user_id: Any, send: Callable[[Ad], Awaitable[Any]], locale: Optional[str] = None
|
|
218
|
+
) -> bool:
|
|
219
|
+
ad = await self.next_ad(user_id=user_id, locale=locale)
|
|
220
|
+
if ad is None:
|
|
221
|
+
return False
|
|
222
|
+
await send(ad)
|
|
223
|
+
return await self.mark_shown(ad.placement_id)
|
|
224
|
+
|
|
225
|
+
def _detect_backend(self) -> str:
|
|
226
|
+
try:
|
|
227
|
+
import httpx # noqa: F401
|
|
228
|
+
return "httpx"
|
|
229
|
+
except ImportError:
|
|
230
|
+
pass
|
|
231
|
+
try:
|
|
232
|
+
import aiohttp # noqa: F401
|
|
233
|
+
return "aiohttp"
|
|
234
|
+
except ImportError as exc:
|
|
235
|
+
raise EasyAdError(
|
|
236
|
+
"AsyncEasyAd a besoin de httpx ou aiohttp : pip install httpx",
|
|
237
|
+
code="missing_dependency",
|
|
238
|
+
) from exc
|
|
239
|
+
|
|
240
|
+
async def _request(self, method: str, path: str, payload: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
241
|
+
url = self.base_url + path
|
|
242
|
+
headers = self._headers(payload is not None)
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
if self._client_factory == "httpx":
|
|
246
|
+
import httpx
|
|
247
|
+
|
|
248
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
249
|
+
response = await client.request(method, url, json=payload, headers=headers)
|
|
250
|
+
return self._handle(response.status_code, response.text)
|
|
251
|
+
|
|
252
|
+
import aiohttp
|
|
253
|
+
|
|
254
|
+
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
|
255
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
256
|
+
async with session.request(method, url, json=payload, headers=headers) as response:
|
|
257
|
+
return self._handle(response.status, await response.text())
|
|
258
|
+
except EasyAdError:
|
|
259
|
+
raise
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
return self._failure(exc)
|
|
File without changes
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easyyad
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Show EasyAd ads in your Telegram bot and earn 50% of what advertisers pay.
|
|
5
|
+
Author-email: EasyAd <support@easyyad.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://easyyad.com
|
|
8
|
+
Project-URL: Documentation, https://easyyad.com/docs
|
|
9
|
+
Keywords: telegram,telegram-bot,bot,ads,advertising,monetization,aiogram,easyad
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Classifier: Topic :: Communications :: Chat
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Provides-Extra: async
|
|
23
|
+
Requires-Dist: httpx>=0.27; extra == "async"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# easyyad (Python)
|
|
27
|
+
|
|
28
|
+
> Le paquet s'appelle **easyyad**, comme le domaine : `easyad` était déjà pris sur PyPI par une
|
|
29
|
+
> bibliothèque Active Directory sans rapport avec nous.
|
|
30
|
+
|
|
31
|
+
Affichez des publicités EasyAd dans votre bot Telegram et touchez **50 %** de ce que l'annonceur paie.
|
|
32
|
+
|
|
33
|
+
> **English** — Show EasyAd ads in your Telegram bot and earn **50%** of what advertisers pay.
|
|
34
|
+
> `pip install easyyad` (or `easyyad[async]` for aiogram), create `EasyAd(api_key=...)`, then call
|
|
35
|
+
> `next_ad(...)` and `mark_shown(...)` after your bot has answered. Python 3.9+, no required dependency.
|
|
36
|
+
> Full documentation: https://easyyad.com/docs
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install easyyad # client synchrone, sans dépendance
|
|
40
|
+
pip install easyyad[async] # ajoute httpx pour la version asynchrone
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## En trois lignes
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import os
|
|
47
|
+
from easyyad import EasyAd
|
|
48
|
+
|
|
49
|
+
easyad = EasyAd(api_key=os.environ["EASYAD_API_KEY"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Récupérez votre clé dans votre espace EasyAd, onglet **Bots** → votre bot → **Clé d'intégration**.
|
|
53
|
+
Elle n'est affichée qu'une fois : nous n'en gardons qu'une empreinte, nous ne pouvons pas vous la
|
|
54
|
+
redonner, seulement la remplacer.
|
|
55
|
+
|
|
56
|
+
## Montrer une annonce
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
ad = easyad.next_ad(user_id=message.from_user.id, locale=message.from_user.language_code)
|
|
60
|
+
|
|
61
|
+
if ad:
|
|
62
|
+
bot.send_message(
|
|
63
|
+
message.chat.id, ad.text,
|
|
64
|
+
parse_mode="HTML", reply_markup=ad.reply_markup,
|
|
65
|
+
)
|
|
66
|
+
easyad.mark_shown(ad.placement_id) # sans cette ligne, vous n'êtes pas payé
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`next_ad` renvoie `None` quand il n'y a rien à montrer, quand cet utilisateur a déjà vu son quota du
|
|
70
|
+
jour, ou si EasyAd est momentanément injoignable. Dans les trois cas, votre bot continue comme si
|
|
71
|
+
rien ne s'était passé : **une panne chez nous ne doit jamais casser votre bot.**
|
|
72
|
+
|
|
73
|
+
### Le raccourci `serve()`
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
easyad.serve(
|
|
77
|
+
user_id=message.from_user.id,
|
|
78
|
+
locale=message.from_user.language_code,
|
|
79
|
+
send=lambda ad: bot.send_message(
|
|
80
|
+
message.chat.id, ad.text, parse_mode="HTML", reply_markup=ad.reply_markup,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Demande, envoie, confirme. Si votre fonction `send` lève une exception, rien n'est confirmé — donc
|
|
86
|
+
rien ne vous est compté, et l'annonceur n'est pas facturé.
|
|
87
|
+
|
|
88
|
+
## Bots asynchrones (aiogram, python-telegram-bot)
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from easyyad import AsyncEasyAd
|
|
92
|
+
|
|
93
|
+
easyad = AsyncEasyAd(api_key=os.environ["EASYAD_API_KEY"])
|
|
94
|
+
|
|
95
|
+
@dp.message()
|
|
96
|
+
async def handler(message: Message):
|
|
97
|
+
await repondre_normalement(message)
|
|
98
|
+
|
|
99
|
+
ad = await easyad.next_ad(user_id=message.from_user.id, locale=message.from_user.language_code)
|
|
100
|
+
if ad:
|
|
101
|
+
await message.answer(ad.text, parse_mode="HTML", reply_markup=ad.reply_markup)
|
|
102
|
+
await easyad.mark_shown(ad.placement_id)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`AsyncEasyAd` utilise `httpx` s'il est installé, sinon `aiohttp`. Si aucune des deux n'est présente,
|
|
106
|
+
l'erreur arrive à la construction du client — pas au premier appel en production.
|
|
107
|
+
|
|
108
|
+
## Avec une image
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
if ad.image_url:
|
|
112
|
+
bot.send_photo(chat_id, ad.image_url, caption=ad.text, parse_mode="HTML", reply_markup=ad.reply_markup)
|
|
113
|
+
else:
|
|
114
|
+
bot.send_message(chat_id, ad.text, parse_mode="HTML", reply_markup=ad.reply_markup)
|
|
115
|
+
|
|
116
|
+
easyad.mark_shown(ad.placement_id)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Vérifier l'intégration
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
print(easyad.health())
|
|
123
|
+
# {'ok': True, 'bot': {'name': 'Mon bot', 'status': 'approved', 'serving': True}, 'limits': {...}}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Appelez-le au démarrage : vous saurez tout de suite si la clé est bonne et si votre bot est approuvé.
|
|
127
|
+
|
|
128
|
+
## Ce que l'on reçoit de vous
|
|
129
|
+
|
|
130
|
+
`user_id` est **haché avant l'envoi** (SHA-256, tronqué à 32 caractères, salé avec votre clé). Nous ne
|
|
131
|
+
voyons jamais l'identifiant Telegram de vos utilisateurs, et comme le sel est votre clé, deux
|
|
132
|
+
partenaires ne peuvent pas recouper leurs audiences. Cette empreinte ne sert qu'à une chose : ne pas
|
|
133
|
+
montrer trop d'annonces à la même personne.
|
|
134
|
+
|
|
135
|
+
Vous pouvez choisir votre propre sel avec `user_salt=` — gardez-le stable, sinon le plafond de
|
|
136
|
+
fréquence repart de zéro à chaque changement.
|
|
137
|
+
|
|
138
|
+
## Options
|
|
139
|
+
|
|
140
|
+
| Option | Défaut | À quoi ça sert |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `api_key` | — | Votre clé secrète. Obligatoire. |
|
|
143
|
+
| `base_url` | `https://api.easyyad.com/api/v1/sdk/bot` | Pour viser un environnement de test. |
|
|
144
|
+
| `timeout` | `4.0` | Secondes. Au-delà, on renonce : votre bot doit rester rapide. |
|
|
145
|
+
| `raise_on_error` | `False` | `True` pour gérer les exceptions vous-même. |
|
|
146
|
+
| `user_salt` | la clé | Sel du hachage des identifiants. |
|
|
147
|
+
|
|
148
|
+
La journalisation passe par le logger standard `easyyad` :
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
import logging
|
|
152
|
+
logging.getLogger("easyyad").setLevel(logging.DEBUG)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Fréquence
|
|
156
|
+
|
|
157
|
+
Deux limites, réglables dans votre espace EasyAd :
|
|
158
|
+
|
|
159
|
+
- **annonces par utilisateur et par jour** (3 par défaut) ;
|
|
160
|
+
- **délai minimum entre deux annonces** pour la même personne (60 minutes par défaut).
|
|
161
|
+
|
|
162
|
+
Elles existent pour protéger votre audience : une conversation privée saturée de publicité fait
|
|
163
|
+
partir vos utilisateurs, et c'est votre revenu qui disparaît avec eux. Vous pouvez les resserrer
|
|
164
|
+
autant que vous voulez.
|
|
165
|
+
|
|
166
|
+
## Rémunération
|
|
167
|
+
|
|
168
|
+
Vous êtes payé sur les annonces **confirmées**. Une annonce demandée mais jamais confirmée est
|
|
169
|
+
abandonnée au bout de 30 minutes et ne compte pour personne. Les gains apparaissent dans votre
|
|
170
|
+
portefeuille après la période de vérification, puis deviennent retirables.
|
|
171
|
+
|
|
172
|
+
Documentation complète : <https://www.easyyad.com/docs/bots>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
easyyad/__init__.py
|
|
5
|
+
easyyad/client.py
|
|
6
|
+
easyyad/py.typed
|
|
7
|
+
easyyad.egg-info/PKG-INFO
|
|
8
|
+
easyyad.egg-info/SOURCES.txt
|
|
9
|
+
easyyad.egg-info/dependency_links.txt
|
|
10
|
+
easyyad.egg-info/requires.txt
|
|
11
|
+
easyyad.egg-info/top_level.txt
|
|
12
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
easyyad
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "easyyad"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Show EasyAd ads in your Telegram bot and earn 50% of what advertisers pay."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "EasyAd", email = "support@easyyad.com" }]
|
|
9
|
+
keywords = ["telegram", "telegram-bot", "bot", "ads", "advertising", "monetization", "aiogram", "easyad"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 5 - Production/Stable",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Framework :: AsyncIO",
|
|
18
|
+
"Topic :: Communications :: Chat",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
async = ["httpx>=0.27"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://easyyad.com"
|
|
27
|
+
Documentation = "https://easyyad.com/docs"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["setuptools>=68"]
|
|
31
|
+
build-backend = "setuptools.build_meta"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools]
|
|
34
|
+
packages = ["easyyad"]
|
|
35
|
+
license-files = ["LICENSE"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
easyyad = ["py.typed"]
|
easyyad-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Tests du SDK Python : aucun appel réseau réel, le transport est remplacé."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import unittest
|
|
6
|
+
import urllib.error
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
11
|
+
|
|
12
|
+
from easyyad import Ad, EasyAd, EasyAdError, hash_user_id # noqa: E402
|
|
13
|
+
|
|
14
|
+
AD_PAYLOAD = {
|
|
15
|
+
"ad": {
|
|
16
|
+
"placement_id": "pl_abc1234567",
|
|
17
|
+
"text": "<b>Offre</b>\n\nTexte\n\n#Ad #EasyAd",
|
|
18
|
+
"parse_mode": "HTML",
|
|
19
|
+
"reply_markup": {"inline_keyboard": [[{"text": "Découvrir", "url": "https://go.easyyad.com/r/xyz"}]]},
|
|
20
|
+
"image_url": None,
|
|
21
|
+
"label": "#Ad #EasyAd",
|
|
22
|
+
"confirm_within_seconds": 1800,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FakeResponse:
|
|
28
|
+
def __init__(self, status, body):
|
|
29
|
+
self.status = status
|
|
30
|
+
self._body = json.dumps(body).encode() if body is not None else b""
|
|
31
|
+
|
|
32
|
+
def read(self):
|
|
33
|
+
return self._body
|
|
34
|
+
|
|
35
|
+
def __enter__(self):
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def __exit__(self, *a):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def patch_urlopen(responses):
|
|
43
|
+
"""Remplace urlopen ; `responses` est une liste de (status, body) ou d'exceptions."""
|
|
44
|
+
calls = []
|
|
45
|
+
queue = list(responses)
|
|
46
|
+
|
|
47
|
+
def fake(request, timeout=None):
|
|
48
|
+
calls.append({
|
|
49
|
+
"url": request.full_url,
|
|
50
|
+
"method": request.method,
|
|
51
|
+
"headers": dict(request.headers),
|
|
52
|
+
"body": json.loads(request.data) if request.data else None,
|
|
53
|
+
"timeout": timeout,
|
|
54
|
+
})
|
|
55
|
+
item = queue.pop(0) if len(queue) > 1 else queue[0]
|
|
56
|
+
if isinstance(item, BaseException):
|
|
57
|
+
raise item
|
|
58
|
+
return FakeResponse(*item)
|
|
59
|
+
|
|
60
|
+
return mock.patch("urllib.request.urlopen", side_effect=fake), calls
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestKeyAndHashing(unittest.TestCase):
|
|
64
|
+
def test_la_cle_est_obligatoire(self):
|
|
65
|
+
with self.assertRaises(EasyAdError):
|
|
66
|
+
EasyAd(api_key="")
|
|
67
|
+
|
|
68
|
+
def test_l_identifiant_ne_quitte_jamais_le_bot_en_clair(self):
|
|
69
|
+
patch, calls = patch_urlopen([(200, AD_PAYLOAD)])
|
|
70
|
+
with patch:
|
|
71
|
+
EasyAd(api_key="ea_live_test").next_ad(user_id=123456789)
|
|
72
|
+
|
|
73
|
+
sent = calls[0]["body"]["user_ref"]
|
|
74
|
+
self.assertEqual(len(sent), 32)
|
|
75
|
+
self.assertNotIn("123456789", sent)
|
|
76
|
+
|
|
77
|
+
def test_la_meme_personne_donne_la_meme_empreinte(self):
|
|
78
|
+
self.assertEqual(hash_user_id(7, "sel"), hash_user_id(7, "sel"))
|
|
79
|
+
self.assertNotEqual(hash_user_id(7, "sel-a"), hash_user_id(7, "sel-b"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestNextAd(unittest.TestCase):
|
|
83
|
+
def test_une_annonce_est_renvoyee_en_objet(self):
|
|
84
|
+
patch, _ = patch_urlopen([(200, AD_PAYLOAD)])
|
|
85
|
+
with patch:
|
|
86
|
+
ad = EasyAd(api_key="k").next_ad(user_id=1, locale="fr")
|
|
87
|
+
|
|
88
|
+
self.assertIsInstance(ad, Ad)
|
|
89
|
+
self.assertEqual(ad.placement_id, "pl_abc1234567")
|
|
90
|
+
self.assertEqual(ad.parse_mode, "HTML")
|
|
91
|
+
self.assertIsNone(ad.image_url)
|
|
92
|
+
|
|
93
|
+
def test_la_langue_est_transmise(self):
|
|
94
|
+
patch, calls = patch_urlopen([(200, AD_PAYLOAD)])
|
|
95
|
+
with patch:
|
|
96
|
+
EasyAd(api_key="k").next_ad(user_id=1, locale="fr")
|
|
97
|
+
self.assertEqual(calls[0]["body"]["locale"], "fr")
|
|
98
|
+
|
|
99
|
+
def test_sans_langue_le_champ_est_absent(self):
|
|
100
|
+
patch, calls = patch_urlopen([(200, AD_PAYLOAD)])
|
|
101
|
+
with patch:
|
|
102
|
+
EasyAd(api_key="k").next_ad(user_id=1)
|
|
103
|
+
self.assertNotIn("locale", calls[0]["body"])
|
|
104
|
+
|
|
105
|
+
def test_user_id_est_exige(self):
|
|
106
|
+
patch, _ = patch_urlopen([(200, AD_PAYLOAD)])
|
|
107
|
+
with patch, self.assertRaises(EasyAdError):
|
|
108
|
+
EasyAd(api_key="k").next_ad(user_id=None)
|
|
109
|
+
|
|
110
|
+
def test_aucune_annonce_renvoie_none(self):
|
|
111
|
+
patch, _ = patch_urlopen([(200, {"ad": None, "reason": "no_fill"})])
|
|
112
|
+
with patch:
|
|
113
|
+
self.assertIsNone(EasyAd(api_key="k").next_ad(user_id=1))
|
|
114
|
+
|
|
115
|
+
def test_la_cle_part_dans_l_entete_authorization(self):
|
|
116
|
+
patch, calls = patch_urlopen([(200, AD_PAYLOAD)])
|
|
117
|
+
with patch:
|
|
118
|
+
EasyAd(api_key="ea_live_secret").next_ad(user_id=1)
|
|
119
|
+
self.assertEqual(calls[0]["headers"]["Authorization"], "Bearer ea_live_secret")
|
|
120
|
+
|
|
121
|
+
def test_l_adresse_de_base_peut_etre_remplacee(self):
|
|
122
|
+
patch, calls = patch_urlopen([(200, AD_PAYLOAD)])
|
|
123
|
+
with patch:
|
|
124
|
+
EasyAd(api_key="k", base_url="https://api.test.local/sdk/bot/").next_ad(user_id=1)
|
|
125
|
+
self.assertEqual(calls[0]["url"], "https://api.test.local/sdk/bot/ads/next")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestErrors(unittest.TestCase):
|
|
129
|
+
def test_un_quota_atteint_renvoie_none_sans_exception(self):
|
|
130
|
+
err = urllib.error.HTTPError(
|
|
131
|
+
"u", 429, "Too Many", {},
|
|
132
|
+
__import__("io").BytesIO(json.dumps({"error": "frequency_capped", "message": "quota"}).encode()),
|
|
133
|
+
)
|
|
134
|
+
patch, _ = patch_urlopen([err])
|
|
135
|
+
with patch:
|
|
136
|
+
self.assertIsNone(EasyAd(api_key="k").next_ad(user_id=1))
|
|
137
|
+
|
|
138
|
+
def test_une_panne_reseau_ne_casse_pas_le_bot(self):
|
|
139
|
+
patch, _ = patch_urlopen([urllib.error.URLError("ECONNREFUSED")])
|
|
140
|
+
with patch:
|
|
141
|
+
self.assertIsNone(EasyAd(api_key="k").next_ad(user_id=1))
|
|
142
|
+
|
|
143
|
+
def test_raise_on_error_laisse_l_appelant_decider(self):
|
|
144
|
+
patch, _ = patch_urlopen([urllib.error.URLError("ECONNREFUSED")])
|
|
145
|
+
with patch, self.assertRaises(EasyAdError):
|
|
146
|
+
EasyAd(api_key="k", raise_on_error=True).next_ad(user_id=1)
|
|
147
|
+
|
|
148
|
+
def test_une_cle_invalide_est_signalee(self):
|
|
149
|
+
err = urllib.error.HTTPError(
|
|
150
|
+
"u", 401, "Unauthorized", {},
|
|
151
|
+
__import__("io").BytesIO(json.dumps({"error": "invalid_api_key", "message": "Clé inconnue"}).encode()),
|
|
152
|
+
)
|
|
153
|
+
patch, _ = patch_urlopen([err])
|
|
154
|
+
with patch, self.assertRaises(EasyAdError) as ctx:
|
|
155
|
+
EasyAd(api_key="mauvaise", raise_on_error=True).next_ad(user_id=1)
|
|
156
|
+
|
|
157
|
+
self.assertEqual(ctx.exception.code, "invalid_api_key")
|
|
158
|
+
self.assertEqual(ctx.exception.status, 401)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestMarkShown(unittest.TestCase):
|
|
162
|
+
def test_la_confirmation_appelle_la_bonne_adresse(self):
|
|
163
|
+
patch, calls = patch_urlopen([(200, {"status": "published"})])
|
|
164
|
+
with patch:
|
|
165
|
+
self.assertTrue(EasyAd(api_key="k").mark_shown("pl_abc1234567"))
|
|
166
|
+
self.assertTrue(calls[0]["url"].endswith("/ads/pl_abc1234567/shown"))
|
|
167
|
+
|
|
168
|
+
def test_confirmer_sans_identifiant_ne_fait_rien(self):
|
|
169
|
+
patch, calls = patch_urlopen([(200, {})])
|
|
170
|
+
with patch:
|
|
171
|
+
self.assertFalse(EasyAd(api_key="k").mark_shown(""))
|
|
172
|
+
self.assertEqual(calls, [])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TestServe(unittest.TestCase):
|
|
176
|
+
def test_serve_enchaine_demande_envoi_et_confirmation(self):
|
|
177
|
+
patch, calls = patch_urlopen([(200, AD_PAYLOAD), (200, {"status": "published"})])
|
|
178
|
+
sent = []
|
|
179
|
+
with patch:
|
|
180
|
+
done = EasyAd(api_key="k").serve(user_id=5, send=lambda ad: sent.append(ad.placement_id))
|
|
181
|
+
|
|
182
|
+
self.assertTrue(done)
|
|
183
|
+
self.assertEqual(sent, ["pl_abc1234567"])
|
|
184
|
+
self.assertEqual(len(calls), 2)
|
|
185
|
+
|
|
186
|
+
def test_si_l_envoi_echoue_rien_n_est_confirme(self):
|
|
187
|
+
patch, calls = patch_urlopen([(200, AD_PAYLOAD)])
|
|
188
|
+
|
|
189
|
+
def boom(ad):
|
|
190
|
+
raise RuntimeError("Telegram 403")
|
|
191
|
+
|
|
192
|
+
with patch, self.assertRaises(RuntimeError):
|
|
193
|
+
EasyAd(api_key="k").serve(user_id=5, send=boom)
|
|
194
|
+
|
|
195
|
+
self.assertEqual(len(calls), 1) # la confirmation n'est jamais partie
|
|
196
|
+
|
|
197
|
+
def test_serve_sans_annonce_ne_rappelle_pas_send(self):
|
|
198
|
+
patch, _ = patch_urlopen([(200, {"ad": None})])
|
|
199
|
+
called = []
|
|
200
|
+
with patch:
|
|
201
|
+
done = EasyAd(api_key="k").serve(user_id=5, send=lambda ad: called.append(1))
|
|
202
|
+
self.assertFalse(done)
|
|
203
|
+
self.assertEqual(called, [])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class TestHealth(unittest.TestCase):
|
|
207
|
+
def test_health_renvoie_la_reponse_du_serveur(self):
|
|
208
|
+
patch, calls = patch_urlopen([(200, {"ok": True, "bot": {"name": "Mon bot"}})])
|
|
209
|
+
with patch:
|
|
210
|
+
body = EasyAd(api_key="k").health()
|
|
211
|
+
self.assertEqual(body["bot"]["name"], "Mon bot")
|
|
212
|
+
self.assertEqual(calls[0]["method"], "GET")
|
|
213
|
+
self.assertIsNone(calls[0]["body"])
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
unittest.main(verbosity=2)
|