Metadata-Version: 2.4
Name: easytarget
Version: 0.2.0b1
Summary: Python SDK for the EasyTargetBot ad-delivery API
Author: EasyTargetBot
License: MIT
License-File: LICENSE
Keywords: ads,easytargetbot,sdk,telegram
Requires-Python: >=3.9
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# easytarget

Python SDK for the [EasyTargetBot](https://easytargetbot.com) ad-delivery API.
Forward a Telegram update; the platform picks an eligible campaign, sends it into
the user's chat via your bot, and bills the advertiser. You get a typed result.

<!-- README-I18N:START -->

**English** | [Русский](./README.ru.md) | [Oʻzbekcha](./README.uz.md)

<!-- README-I18N:END -->

## Install

```bash
pip install easytarget
```

## Usage (sync)

```python
from easytarget import EasyTargetClient

client = EasyTargetClient(api_key="…")     # base_url defaults to the platform
result = client.show_ad(update)            # dict | aiogram Update | PTB Update
if result.sent:
    print(result.campaign_id, result.message_id)
else:
    print(result.reason)                   # DeliveryReason.NO_AD / NOT_PRIVATE / …
```

## Usage (async, aiogram)

```python
from aiogram.types import Message, Update
from easytarget import AsyncEasyTargetClient

client = AsyncEasyTargetClient(api_key="…")       # create once, reuse for every update

@dp.message(F.text & ~F.text.startswith("/"))     # real messages, not commands
async def on_message(message: Message, event_update: Update) -> None:
    await handle(message)                         # do your bot's actual work first
    result = await client.show_ad(event_update)   # pass the whole Update, not the Message
    if not result.sent:
        logging.debug("no ad shown: %s", result.reason)

@dp.shutdown()
async def on_shutdown():
    await client.aclose()                         # close the HTTP pool on shutdown
```

Pass the **whole `Update`**, not the `Message`. aiogram injects the parent update
into every handler as `event_update` — declare that parameter and the SDK forwards
the full update, including the `update_id` the platform requires. Handing it
`message` instead drops `update_id`, and the request is rejected
(`DeliveryReason.MISSING_UPDATE_ID`).

Only `api_key` is required. `base_url` defaults to the platform endpoint shown
alongside your API key; pass `base_url=…` only if you were given a dedicated URL
or are pinned to an older one. Both fall back to the `EASYTARGET_API_KEY` /
`EASYTARGET_BASE_URL` environment variables.

## Best practices — show ads on real actions, don't spam

> ⚠️ **Spamming ads can get your bot banned.** Showing an ad on every update, on
> bot commands (`/start`, `/help`, …), or pumping a low-traffic / artificial-traffic
> bot violates Telegram's rules and EasyTargetBot's policy and will get the bot
> flagged and removed. Show **one** ad in response to a **genuine user action**,
> and let the platform's eligibility filter + per-user frequency cap do the rest.

**Good — tied to a real action, deliberate:**

```python
# After the user finishes something meaningful, offer a single ad.
@dp.message(Command("results"))
async def on_results(message: Message, event_update: Update):
    await send_results(message)
    await client.show_ad(event_update)     # ✅ one ad, on a real action
```

**Avoid — spammy placement that risks a ban:**

```python
@dp.message()                              # fires on EVERYTHING, including commands
async def on_any(message: Message, event_update: Update):
    await client.show_ad(event_update)     # ❌ an ad on every message = spam
    await client.show_ad(event_update)     # ❌ never show several in a row
```

The server already filters out commands, non-private chats, and bots, and caps
how often a given user is shown an ad — but those are a safety net, not a license
to call `show_ad` indiscriminately. Be intentional about where you place it.

## Results & errors

`show_ad` returns an `AdResult(sent, campaign_id, unique, message_id, reason, raw)`.
When `sent` is `False`, `reason` is a `DeliveryReason` — a filter reason
(`UNSUPPORTED_UPDATE`, `NO_USER`, `BOT_SENDER`, `NOT_PRIVATE`, `COMMAND`) or a
delivery reason (`BOT_NOT_SERVING`, `FREQUENCY_CAP`, `NO_AD`, `SEND_FAILED`).

It raises `InvalidApiKey` / `APIError` on a rejected request and `RequestError`
on a network/timeout failure (all subclasses of `EasyTargetError`). `APIError`
carries `status_code`, `code`, `message`, `detail`, and `retry_after`.

## Configuration

```python
from easytarget import EasyTargetClient, RetryPolicy

client = EasyTargetClient(
    api_key="…",
    base_url="https://easytarget.jakhongir.dev",  # optional override
    timeout=10.0,                                # per-request timeout (seconds)
    retries=2,                                   # extra attempts after the first
)
```

**Retries are deliberately conservative.** Because `/api/v1/ad/send` delivers an ad and
charges the advertiser, the SDK only retries failures where the request provably
never reached the server (connection/pool errors) plus `429` / `503` responses
(honoring `Retry-After`, with exponential backoff + jitter). It never retries
read/write timeouts or other `5xx`, which could mean the ad was already sent —
retrying those would double-deliver and double-charge. Set `retries=0` to disable.

For full control pass a `RetryPolicy`:

```python
client = EasyTargetClient(
    api_key="…",
    retry_policy=RetryPolicy(max_retries=3, backoff_factor=0.5, max_backoff=30.0),
)
```

Bring your own `httpx` client (proxies, custom TLS, connection-pool tuning); the
SDK still applies auth and the request path, and will not close a client you own:

```python
import httpx
from easytarget import EasyTargetClient

http = httpx.Client(proxy="http://localhost:8080", timeout=5.0)
client = EasyTargetClient(api_key="…", http_client=http)
```

The package ships type hints (`py.typed`) for mypy/pyright.

## Logging

Both clients log each ad request — **on by default**, to the stdlib `easytarget`
logger. Logging is privacy-safe: only the `update_id` and the outcome are logged,
never the update payload or message text.

```python
import logging
logging.basicConfig(level=logging.INFO)        # see the default "easytarget" logger

client = EasyTargetClient(api_key="…")
# INFO  easytarget: ad sent update_id=85123456 campaign=cmp_42 unique=True
# INFO  easytarget: ad not sent update_id=85123457 reason=frequency_cap
# WARNING easytarget: retry 1/2 after 503 update_id=85123458
# ERROR easytarget: request failed update_id=85123459 [401] invalid_api_key
```

The `logger` is pluggable — pass any object with `debug/info/warning/error`
methods. Stdlib `logging.Logger` and `loguru`'s logger both work directly, no
adapter needed:

```python
from loguru import logger
client = EasyTargetClient(api_key="…", logger=logger)              # loguru
client = EasyTargetClient(api_key="…", logger=logging.getLogger("mybot"))  # stdlib
```

Turn it off entirely with `logging_enabled=False` (wins even if a `logger` is
passed). Request start is logged at `DEBUG`, outcomes at `INFO`, retries at
`WARNING`, and final failures at `ERROR`. The `LoggerLike` protocol is exported
if you want to type your own logger. Both options work identically on
`AsyncEasyTargetClient`.

## License

MIT
