@easyyad/bot-sdk 0.0.0-stage → 1.0.0

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.
package/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.
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # @easyyad/bot-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Affichez des publicités EasyAd dans votre bot Telegram et touchez **50 %** de ce que l'annonceur paie.
4
+
5
+ > **English** — Show EasyAd ads in your Telegram bot and earn **50%** of what advertisers pay.
6
+ > `npm install @easyyad/bot-sdk`, create `new EasyAd({ apiKey })`, then call `easyad.serve({ userId, locale, send })`
7
+ > after your bot has answered. Works with ESM (`import`) and CommonJS (`require`), Node 18+. TypeScript types included.
8
+ > Full documentation: https://easyyad.com/docs
9
+
10
+ ```bash
11
+ npm install @easyyad/bot-sdk
12
+ ```
13
+
14
+ ## En trois lignes
15
+
16
+ ```js
17
+ import { EasyAd } from '@easyyad/bot-sdk';
18
+
19
+ const easyad = new EasyAd({ apiKey: process.env.EASYAD_API_KEY });
20
+ ```
21
+
22
+ Récupérez votre clé dans votre espace EasyAd, onglet **Bots** → votre bot → **Clé d'intégration**.
23
+ Elle n'est affichée qu'une fois : nous n'en gardons qu'une empreinte, nous ne pouvons pas vous la
24
+ redonner, seulement la remplacer.
25
+
26
+ ## Montrer une annonce
27
+
28
+ ```js
29
+ bot.on('message', async (ctx) => {
30
+ await repondreNormalement(ctx); // votre bot fait d'abord son travail
31
+
32
+ const ad = await easyad.nextAd({
33
+ userId: ctx.from.id,
34
+ locale: ctx.from.language_code,
35
+ });
36
+
37
+ if (ad) {
38
+ await ctx.reply(ad.text, { parse_mode: 'HTML', reply_markup: ad.replyMarkup });
39
+ await easyad.markShown(ad.placementId); // sans cette ligne, vous n'êtes pas payé
40
+ }
41
+ });
42
+ ```
43
+
44
+ `nextAd` renvoie `null` 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
+ ```js
51
+ await easyad.serve({
52
+ userId: ctx.from.id,
53
+ locale: ctx.from.language_code,
54
+ send: (ad) => ctx.reply(ad.text, { parse_mode: 'HTML', reply_markup: ad.replyMarkup }),
55
+ });
56
+ ```
57
+
58
+ Demande, envoie, confirme. Si votre `send` échoue, rien n'est confirmé — donc rien ne vous est
59
+ compté, et l'annonceur n'est pas facturé.
60
+
61
+ ## Avec une image
62
+
63
+ ```js
64
+ if (ad.imageUrl) {
65
+ await ctx.replyWithPhoto(ad.imageUrl, { caption: ad.text, parse_mode: 'HTML', reply_markup: ad.replyMarkup });
66
+ } else {
67
+ await ctx.reply(ad.text, { parse_mode: 'HTML', reply_markup: ad.replyMarkup });
68
+ }
69
+ await easyad.markShown(ad.placementId);
70
+ ```
71
+
72
+ ## Vérifier l'intégration
73
+
74
+ ```js
75
+ console.log(await easyad.health());
76
+ // { ok: true, bot: { name: 'Mon bot', status: 'approved', serving: true }, limits: { … } }
77
+ ```
78
+
79
+ Appelez-le au démarrage : vous saurez tout de suite si la clé est bonne et si votre bot est approuvé.
80
+
81
+ ## Ce que l'on reçoit de vous
82
+
83
+ `userId` est **haché avant l'envoi** (SHA-256, tronqué à 32 caractères, salé avec votre clé). Nous ne
84
+ voyons jamais l'identifiant Telegram de vos utilisateurs, et comme le sel est votre clé, deux
85
+ partenaires ne peuvent pas recouper leurs audiences. Cette empreinte ne sert qu'à une chose : ne pas
86
+ montrer trop d'annonces à la même personne.
87
+
88
+ Vous pouvez choisir votre propre sel avec `userSalt` — gardez-le stable, sinon le plafond de
89
+ fréquence repart de zéro à chaque changement.
90
+
91
+ ## Options
92
+
93
+ | Option | Défaut | À quoi ça sert |
94
+ |---|---|---|
95
+ | `apiKey` | — | Votre clé secrète. Obligatoire. |
96
+ | `baseUrl` | `https://api.easyyad.com/api/v1/sdk/bot` | Pour viser un environnement de test. |
97
+ | `timeoutMs` | `4000` | Au-delà, on renonce : votre bot doit rester rapide. |
98
+ | `throwOnError` | `false` | `true` pour gérer les erreurs vous-même. |
99
+ | `userSalt` | la clé | Sel du hachage des identifiants. |
100
+ | `logger` | — | `(level, message, extra) => void`, branchez votre journalisation. |
101
+
102
+ ## Fréquence
103
+
104
+ Deux limites, réglables dans votre espace EasyAd :
105
+
106
+ - **annonces par utilisateur et par jour** (3 par défaut) ;
107
+ - **délai minimum entre deux annonces** pour la même personne (60 minutes par défaut).
108
+
109
+ Elles existent pour protéger votre audience : une conversation privée saturée de publicité fait
110
+ partir vos utilisateurs, et c'est votre revenu qui disparaît avec eux. Vous pouvez les resserrer
111
+ autant que vous voulez.
112
+
113
+ ## Rémunération
114
+
115
+ Vous êtes payé sur les annonces **confirmées**. Une annonce demandée mais jamais confirmée est
116
+ abandonnée au bout de 30 minutes et ne compte pour personne. Les gains apparaissent dans votre
117
+ portefeuille après la période de vérification, puis deviennent retirables.
118
+
119
+ Documentation complète : <https://www.easyyad.com/docs/bots>
package/dist/index.cjs ADDED
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+ /**
3
+ * SDK EasyAd pour les bots — JavaScript / TypeScript (Node 18+).
4
+ *
5
+ * Deux appels suffisent : demander une annonce, puis confirmer qu'elle a bien été envoyée.
6
+ * Rien dans ce SDK ne lève d'erreur par défaut : un réseau coupé ou un quota atteint renvoie
7
+ * simplement `null`, pour qu'une panne chez nous ne casse jamais votre bot.
8
+ */
9
+
10
+ const DEFAULT_BASE = 'https://api.easyyad.com/api/v1/sdk/bot';
11
+
12
+ class EasyAdError extends Error {
13
+ constructor(message, { status, code, retryAfterSeconds } = {}) {
14
+ super(message);
15
+ this.name = 'EasyAdError';
16
+ this.status = status ?? 0;
17
+ this.code = code ?? 'unknown';
18
+ this.retryAfterSeconds = retryAfterSeconds ?? null;
19
+ }
20
+ }
21
+
22
+ /** Identifiant d'utilisateur haché : nous n'avons jamais besoin de l'identifiant réel. */
23
+ async function hashUserId(value, salt) {
24
+ const { createHash } = await import('node:crypto');
25
+ return createHash('sha256').update(`${salt}:${value}`).digest('hex').slice(0, 32);
26
+ }
27
+
28
+ class EasyAd {
29
+ /**
30
+ * @param {object} options
31
+ * @param {string} options.apiKey Clé secrète du bot (ea_live_…).
32
+ * @param {string} [options.baseUrl] Pour pointer vers un environnement de test.
33
+ * @param {number} [options.timeoutMs=4000] Au-delà, on renonce : votre bot doit répondre vite.
34
+ * @param {boolean} [options.throwOnError=false] true pour gérer les erreurs vous-même.
35
+ * @param {string} [options.userSalt] Sel du hachage des identifiants (défaut : la clé).
36
+ * @param {(level: string, message: string, extra?: object) => void} [options.logger]
37
+ */
38
+ constructor({ apiKey, baseUrl = DEFAULT_BASE, timeoutMs = 4000, throwOnError = false, userSalt, logger } = {}) {
39
+ if (!apiKey) throw new EasyAdError('Clé EasyAd manquante : passez { apiKey }.', { code: 'missing_api_key' });
40
+ this.apiKey = apiKey;
41
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
42
+ this.timeoutMs = timeoutMs;
43
+ this.throwOnError = throwOnError;
44
+ this.userSalt = userSalt || apiKey;
45
+ this.logger = logger || (() => {});
46
+ }
47
+
48
+ /**
49
+ * Demande une annonce pour l'utilisateur en face du bot.
50
+ *
51
+ * @param {object} params
52
+ * @param {string|number} params.userId Identifiant chez vous ; il est haché avant l'envoi.
53
+ * @param {string} [params.locale] « fr », « en-US »… pour un meilleur ciblage.
54
+ * @returns {Promise<null|{placementId: string, text: string, parseMode: string, replyMarkup: object, imageUrl: ?string, label: string, confirmWithinSeconds: number}>}
55
+ * `null` quand il n'y a rien à montrer, que l'utilisateur a atteint son quota, ou en cas de problème réseau.
56
+ */
57
+ async nextAd({ userId, locale } = {}) {
58
+ if (userId === undefined || userId === null || userId === '') {
59
+ throw new EasyAdError('userId est requis : il sert à limiter la fréquence par utilisateur.', { code: 'missing_user_id' });
60
+ }
61
+
62
+ const userRef = await hashUserId(String(userId), this.userSalt);
63
+ const body = await this.#post('/ads/next', { user_ref: userRef, locale: locale ?? undefined });
64
+ if (!body || !body.ad) return null;
65
+
66
+ const ad = body.ad;
67
+ return {
68
+ placementId: ad.placement_id,
69
+ text: ad.text,
70
+ parseMode: ad.parse_mode || 'HTML',
71
+ replyMarkup: ad.reply_markup,
72
+ imageUrl: ad.image_url ?? null,
73
+ label: ad.label,
74
+ confirmWithinSeconds: ad.confirm_within_seconds ?? 1800,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Confirme l'envoi. **À appeler après** que l'annonce est partie : c'est cette confirmation qui
80
+ * déclenche votre rémunération. Sans elle, l'annonce n'est ni facturée ni payée.
81
+ *
82
+ * @returns {Promise<boolean>} true si la confirmation est enregistrée.
83
+ */
84
+ async markShown(placementId) {
85
+ if (!placementId) return false;
86
+ const body = await this.#post(`/ads/${encodeURIComponent(placementId)}/shown`, {});
87
+ return Boolean(body && body.status === 'published');
88
+ }
89
+
90
+ /** Vérifie la clé et renvoie les réglages du bot. Pratique au démarrage. */
91
+ async health() {
92
+ return this.#request('GET', '/health');
93
+ }
94
+
95
+ /**
96
+ * Raccourci le plus courant : demande une annonce, l'envoie avec votre fonction, confirme.
97
+ * `send` reçoit l'annonce et doit l'envoyer ; s'il lève une erreur, rien n'est confirmé.
98
+ *
99
+ * @param {object} params
100
+ * @param {string|number} params.userId
101
+ * @param {string} [params.locale]
102
+ * @param {(ad: object) => Promise<unknown>} params.send
103
+ * @returns {Promise<boolean>} true si une annonce a été envoyée et confirmée.
104
+ */
105
+ async serve({ userId, locale, send }) {
106
+ const ad = await this.nextAd({ userId, locale });
107
+ if (!ad) return false;
108
+
109
+ await send(ad); // une erreur ici remonte : pas de confirmation, pas de facturation
110
+ return this.markShown(ad.placementId);
111
+ }
112
+
113
+ async #post(path, payload) {
114
+ return this.#request('POST', path, payload);
115
+ }
116
+
117
+ async #request(method, path, payload) {
118
+ const controller = new AbortController();
119
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
120
+
121
+ try {
122
+ const response = await fetch(this.baseUrl + path, {
123
+ method,
124
+ headers: {
125
+ Authorization: `Bearer ${this.apiKey}`,
126
+ Accept: 'application/json',
127
+ ...(payload ? { 'Content-Type': 'application/json' } : {}),
128
+ },
129
+ body: payload ? JSON.stringify(payload) : undefined,
130
+ signal: controller.signal,
131
+ });
132
+
133
+ const text = await response.text();
134
+ let body = null;
135
+ try { body = text ? JSON.parse(text) : null; } catch { /* réponse non JSON */ }
136
+
137
+ if (!response.ok) {
138
+ const error = new EasyAdError(body?.message || `EasyAd a répondu ${response.status}.`, {
139
+ status: response.status,
140
+ code: body?.error || `http_${response.status}`,
141
+ retryAfterSeconds: body?.retry_after_seconds ?? null,
142
+ });
143
+ // Un quota atteint est une réponse normale, pas une panne : on n'alarme personne.
144
+ if (error.code === 'frequency_capped') {
145
+ this.logger('debug', 'EasyAd : quota de fréquence atteint pour cet utilisateur.', { code: error.code });
146
+ return null;
147
+ }
148
+ if (this.throwOnError) throw error;
149
+ this.logger('warn', `EasyAd : ${error.message}`, { code: error.code, status: error.status });
150
+ return null;
151
+ }
152
+
153
+ return body;
154
+ } catch (e) {
155
+ if (e instanceof EasyAdError) throw e;
156
+ const aborted = e?.name === 'AbortError';
157
+ const error = new EasyAdError(
158
+ aborted ? `EasyAd n'a pas répondu en ${this.timeoutMs} ms.` : `EasyAd est injoignable : ${e?.message || e}`,
159
+ { code: aborted ? 'timeout' : 'network_error' },
160
+ );
161
+ if (this.throwOnError) throw error;
162
+ this.logger('warn', error.message, { code: error.code });
163
+ return null;
164
+ } finally {
165
+ clearTimeout(timer);
166
+ }
167
+ }
168
+ }
169
+
170
+
171
+ module.exports = EasyAd;
172
+ module.exports.EasyAd = EasyAd;
173
+ module.exports.EasyAdError = EasyAdError;
174
+ module.exports.default = EasyAd;
package/index.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ // Types de @easyyad/bot-sdk
2
+
3
+ export interface EasyAdOptions {
4
+ /** Clé secrète du bot (ea_live_…), dans votre espace EasyAd → Bots → Clé d'intégration. */
5
+ apiKey: string;
6
+ /** Pour un environnement de test. Défaut : https://api.easyyad.com/api/v1/sdk/bot */
7
+ baseUrl?: string;
8
+ /** Délai maximal d'un appel (défaut 4000 ms). */
9
+ timeoutMs?: number;
10
+ /** true pour recevoir les erreurs au lieu d'un simple `null`. */
11
+ throwOnError?: boolean;
12
+ /** Sel du hachage des identifiants utilisateurs (défaut : la clé). */
13
+ userSalt?: string;
14
+ logger?: (level: string, message: string, extra?: Record<string, unknown>) => void;
15
+ }
16
+
17
+ export interface EasyAdAd {
18
+ placementId: string;
19
+ text: string;
20
+ parseMode: 'HTML';
21
+ replyMarkup: { inline_keyboard: Array<Array<{ text: string; url: string; style?: string }>> };
22
+ imageUrl: string | null;
23
+ label: string;
24
+ confirmWithinSeconds: number;
25
+ }
26
+
27
+ export interface NextAdParams {
28
+ userId: string | number;
29
+ locale?: string;
30
+ }
31
+
32
+ export declare class EasyAdError extends Error {
33
+ status: number;
34
+ code: string;
35
+ retryAfterSeconds: number | null;
36
+ constructor(message: string, extra?: { status?: number; code?: string; retryAfterSeconds?: number | null });
37
+ }
38
+
39
+ export declare class EasyAd {
40
+ constructor(options: EasyAdOptions);
41
+ nextAd(params: NextAdParams): Promise<EasyAdAd | null>;
42
+ markShown(placementId: string): Promise<boolean>;
43
+ health(): Promise<Record<string, unknown> | null>;
44
+ serve(params: NextAdParams & { send: (ad: EasyAdAd) => Promise<unknown> }): Promise<boolean>;
45
+ }
46
+
47
+ export default EasyAd;
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@easyyad/bot-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Show EasyAd ads in your Telegram bot and earn 50% of what advertisers pay. / Affichez des publicités EasyAd dans votre bot Telegram et soyez rémunéré.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./src/index.js",
8
+ "types": "./index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "import": "./src/index.js",
13
+ "require": "./dist/index.cjs"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": ["src", "dist", "index.d.ts", "README.md", "LICENSE"],
18
+ "sideEffects": false,
19
+ "engines": { "node": ">=18" },
20
+ "scripts": {
21
+ "build": "node scripts/build-cjs.mjs",
22
+ "test": "node scripts/build-cjs.mjs && node --test",
23
+ "prepublishOnly": "npm test"
24
+ },
25
+ "keywords": ["telegram", "telegram-bot", "bot", "ads", "advertising", "monetization", "grammy", "telegraf", "easyad"],
26
+ "homepage": "https://easyyad.com/docs",
27
+ "bugs": { "email": "support@easyyad.com" },
28
+ "author": "EasyAd <support@easyyad.com> (https://easyyad.com)",
29
+ "license": "MIT",
30
+ "publishConfig": { "access": "public" }
31
+ }
package/src/index.js ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * SDK EasyAd pour les bots — JavaScript / TypeScript (Node 18+).
3
+ *
4
+ * Deux appels suffisent : demander une annonce, puis confirmer qu'elle a bien été envoyée.
5
+ * Rien dans ce SDK ne lève d'erreur par défaut : un réseau coupé ou un quota atteint renvoie
6
+ * simplement `null`, pour qu'une panne chez nous ne casse jamais votre bot.
7
+ */
8
+
9
+ const DEFAULT_BASE = 'https://api.easyyad.com/api/v1/sdk/bot';
10
+
11
+ export class EasyAdError extends Error {
12
+ constructor(message, { status, code, retryAfterSeconds } = {}) {
13
+ super(message);
14
+ this.name = 'EasyAdError';
15
+ this.status = status ?? 0;
16
+ this.code = code ?? 'unknown';
17
+ this.retryAfterSeconds = retryAfterSeconds ?? null;
18
+ }
19
+ }
20
+
21
+ /** Identifiant d'utilisateur haché : nous n'avons jamais besoin de l'identifiant réel. */
22
+ async function hashUserId(value, salt) {
23
+ const { createHash } = await import('node:crypto');
24
+ return createHash('sha256').update(`${salt}:${value}`).digest('hex').slice(0, 32);
25
+ }
26
+
27
+ export class EasyAd {
28
+ /**
29
+ * @param {object} options
30
+ * @param {string} options.apiKey Clé secrète du bot (ea_live_…).
31
+ * @param {string} [options.baseUrl] Pour pointer vers un environnement de test.
32
+ * @param {number} [options.timeoutMs=4000] Au-delà, on renonce : votre bot doit répondre vite.
33
+ * @param {boolean} [options.throwOnError=false] true pour gérer les erreurs vous-même.
34
+ * @param {string} [options.userSalt] Sel du hachage des identifiants (défaut : la clé).
35
+ * @param {(level: string, message: string, extra?: object) => void} [options.logger]
36
+ */
37
+ constructor({ apiKey, baseUrl = DEFAULT_BASE, timeoutMs = 4000, throwOnError = false, userSalt, logger } = {}) {
38
+ if (!apiKey) throw new EasyAdError('Clé EasyAd manquante : passez { apiKey }.', { code: 'missing_api_key' });
39
+ this.apiKey = apiKey;
40
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
41
+ this.timeoutMs = timeoutMs;
42
+ this.throwOnError = throwOnError;
43
+ this.userSalt = userSalt || apiKey;
44
+ this.logger = logger || (() => {});
45
+ }
46
+
47
+ /**
48
+ * Demande une annonce pour l'utilisateur en face du bot.
49
+ *
50
+ * @param {object} params
51
+ * @param {string|number} params.userId Identifiant chez vous ; il est haché avant l'envoi.
52
+ * @param {string} [params.locale] « fr », « en-US »… pour un meilleur ciblage.
53
+ * @returns {Promise<null|{placementId: string, text: string, parseMode: string, replyMarkup: object, imageUrl: ?string, label: string, confirmWithinSeconds: number}>}
54
+ * `null` quand il n'y a rien à montrer, que l'utilisateur a atteint son quota, ou en cas de problème réseau.
55
+ */
56
+ async nextAd({ userId, locale } = {}) {
57
+ if (userId === undefined || userId === null || userId === '') {
58
+ throw new EasyAdError('userId est requis : il sert à limiter la fréquence par utilisateur.', { code: 'missing_user_id' });
59
+ }
60
+
61
+ const userRef = await hashUserId(String(userId), this.userSalt);
62
+ const body = await this.#post('/ads/next', { user_ref: userRef, locale: locale ?? undefined });
63
+ if (!body || !body.ad) return null;
64
+
65
+ const ad = body.ad;
66
+ return {
67
+ placementId: ad.placement_id,
68
+ text: ad.text,
69
+ parseMode: ad.parse_mode || 'HTML',
70
+ replyMarkup: ad.reply_markup,
71
+ imageUrl: ad.image_url ?? null,
72
+ label: ad.label,
73
+ confirmWithinSeconds: ad.confirm_within_seconds ?? 1800,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Confirme l'envoi. **À appeler après** que l'annonce est partie : c'est cette confirmation qui
79
+ * déclenche votre rémunération. Sans elle, l'annonce n'est ni facturée ni payée.
80
+ *
81
+ * @returns {Promise<boolean>} true si la confirmation est enregistrée.
82
+ */
83
+ async markShown(placementId) {
84
+ if (!placementId) return false;
85
+ const body = await this.#post(`/ads/${encodeURIComponent(placementId)}/shown`, {});
86
+ return Boolean(body && body.status === 'published');
87
+ }
88
+
89
+ /** Vérifie la clé et renvoie les réglages du bot. Pratique au démarrage. */
90
+ async health() {
91
+ return this.#request('GET', '/health');
92
+ }
93
+
94
+ /**
95
+ * Raccourci le plus courant : demande une annonce, l'envoie avec votre fonction, confirme.
96
+ * `send` reçoit l'annonce et doit l'envoyer ; s'il lève une erreur, rien n'est confirmé.
97
+ *
98
+ * @param {object} params
99
+ * @param {string|number} params.userId
100
+ * @param {string} [params.locale]
101
+ * @param {(ad: object) => Promise<unknown>} params.send
102
+ * @returns {Promise<boolean>} true si une annonce a été envoyée et confirmée.
103
+ */
104
+ async serve({ userId, locale, send }) {
105
+ const ad = await this.nextAd({ userId, locale });
106
+ if (!ad) return false;
107
+
108
+ await send(ad); // une erreur ici remonte : pas de confirmation, pas de facturation
109
+ return this.markShown(ad.placementId);
110
+ }
111
+
112
+ async #post(path, payload) {
113
+ return this.#request('POST', path, payload);
114
+ }
115
+
116
+ async #request(method, path, payload) {
117
+ const controller = new AbortController();
118
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
119
+
120
+ try {
121
+ const response = await fetch(this.baseUrl + path, {
122
+ method,
123
+ headers: {
124
+ Authorization: `Bearer ${this.apiKey}`,
125
+ Accept: 'application/json',
126
+ ...(payload ? { 'Content-Type': 'application/json' } : {}),
127
+ },
128
+ body: payload ? JSON.stringify(payload) : undefined,
129
+ signal: controller.signal,
130
+ });
131
+
132
+ const text = await response.text();
133
+ let body = null;
134
+ try { body = text ? JSON.parse(text) : null; } catch { /* réponse non JSON */ }
135
+
136
+ if (!response.ok) {
137
+ const error = new EasyAdError(body?.message || `EasyAd a répondu ${response.status}.`, {
138
+ status: response.status,
139
+ code: body?.error || `http_${response.status}`,
140
+ retryAfterSeconds: body?.retry_after_seconds ?? null,
141
+ });
142
+ // Un quota atteint est une réponse normale, pas une panne : on n'alarme personne.
143
+ if (error.code === 'frequency_capped') {
144
+ this.logger('debug', 'EasyAd : quota de fréquence atteint pour cet utilisateur.', { code: error.code });
145
+ return null;
146
+ }
147
+ if (this.throwOnError) throw error;
148
+ this.logger('warn', `EasyAd : ${error.message}`, { code: error.code, status: error.status });
149
+ return null;
150
+ }
151
+
152
+ return body;
153
+ } catch (e) {
154
+ if (e instanceof EasyAdError) throw e;
155
+ const aborted = e?.name === 'AbortError';
156
+ const error = new EasyAdError(
157
+ aborted ? `EasyAd n'a pas répondu en ${this.timeoutMs} ms.` : `EasyAd est injoignable : ${e?.message || e}`,
158
+ { code: aborted ? 'timeout' : 'network_error' },
159
+ );
160
+ if (this.throwOnError) throw error;
161
+ this.logger('warn', error.message, { code: error.code });
162
+ return null;
163
+ } finally {
164
+ clearTimeout(timer);
165
+ }
166
+ }
167
+ }
168
+
169
+ export default EasyAd;