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 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>
@@ -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,3 @@
1
+
2
+ [async]
3
+ httpx>=0.27
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)