Metadata-Version: 2.4
Name: polis-bot
Version: 0.3.0
Summary: Async Python client for the Polis Bot API
Author: Polis Team
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: httpx<1,>=0.27
Requires-Dist: pydantic<3,>=2.5
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
Requires-Dist: respx>=0.22; extra == "dev"
Requires-Dist: ruff>=0.8; extra == "dev"

# polis-bot

Async Python client for the **Polis Bot API** — build bots for the [Polis](https://polis.krlv.org) messaging platform.

Requires **Python 3.10+**. Built on [httpx](https://www.python-httpx.org/) and [Pydantic v2](https://docs.pydantic.dev/).

## Installation

```bash
pip install polis-bot
```

## Quick Start

```python
from polis_bot import PolisBot, CommandContext, MessageContext

bot = PolisBot("your-bot-token")

@bot.command("/start", description="Start the bot")
async def start(ctx: CommandContext):
    await ctx.reply(f"Hello {ctx.user.display_name}! 🤖")

@bot.command("/help", description="Show help")
async def help_cmd(ctx: CommandContext):
    await ctx.reply("**Commands:**\n- /start — Greet\n- /help — This message")

@bot.on_message
async def echo(ctx: MessageContext):
    await ctx.reply(f"You said: {ctx.message.content}")

bot.run()
```

Commands are auto-registered with the server on startup. The bot long-polls for updates and dispatches them to the matching handler.

## Command Arguments

Handler type hints drive automatic argument parsing — like FastAPI for bots:

```python
@bot.command("/add", description="Add two numbers")
async def add(ctx: CommandContext, a: int, b: int):
    await ctx.reply(f"{a + b}")
    # /add 3 5 → "8"
```

The last `str` parameter absorbs remaining tokens:

```python
@bot.command("/echo", description="Echo text")
async def echo_cmd(ctx: CommandContext, text: str):
    await ctx.reply(text)
    # /echo hello world → "hello world"
```

Supported types: `str`, `int`, `float`, `bool`, `UUID`, and Pydantic `BaseModel` subclasses. Invalid input returns a usage hint automatically.

## File Handling

Files are attachments on messages — any message (text, command, or standalone) can
carry one or more files. Each attachment is an `Attachment` object with metadata
properties and `download()` / `download_to()` methods — no need to manage URLs.

### Sending files

```python
# File-only message
await bot.send_file(user_id, "image.png", content_type="image/png")

# Text + file in one message
await bot.send_file(user_id, "report.pdf",
                    content_type="application/pdf",
                    content="Here's the report you requested")

# Via context
@bot.command("/cat", description="Send a cat picture")
async def cat(ctx: CommandContext):
    await ctx.reply_file("cat.jpg", content_type="image/jpeg")
```

### Receiving files

File attachments are available on any message via `ctx.message.attachments`.
Each `Attachment` exposes metadata and can download itself:

```python
@bot.on_message
async def handle(ctx: MessageContext):
    if ctx.message.attachments:
        for att in ctx.message.attachments:
            print(f"{att.file_name} ({att.content_type}, {att.size_bytes} bytes)")
            # Download to memory
            data = await att.download()
            # or save to disk
            await att.download_to(f"downloads/{att.file_name}")
        await ctx.reply(f"Got {len(ctx.message.attachments)} file(s)")
    elif ctx.message.content:
        await ctx.reply(f"You said: {ctx.message.content}")
```

Commands can also have file attachments:

```python
@bot.command("/analyze", description="Analyze an uploaded file")
async def analyze(ctx: CommandContext):
    if not ctx.message.attachments:
        await ctx.reply("Please attach a file to analyze")
        return
    att = ctx.message.attachments[0]
    data = await att.download()
    await ctx.reply(f"Analyzed **{att.file_name}**: {len(data)} bytes")
```

## Lifecycle Events

```python
from polis_bot import EventContext

@bot.on_event("BotStarted")
async def on_started(ctx: EventContext):
    await bot.send_message(ctx.user.id, "Welcome! Send /help to get started.")

@bot.on_event("BotBlocked")
async def on_blocked(ctx: EventContext):
    print(f"{ctx.user.display_name} blocked the bot")
```

Event types: `BotStarted`, `BotBlocked`, `BotUnblocked`, `BotAdded`, `BotRemoved`.

## Startup Hook

```python
@bot.on_startup
async def setup():
    print(f"Running as @{bot.me.botname} ({bot.me.display_name})")
```

`bot.me` is populated before startup hooks run and returns cached `BotInfo`.

## Error Handling

The SDK raises typed exceptions for API errors:

```python
from polis_bot import PolisApiError, PolisAuthError, PolisRateLimitError

try:
    await bot.send_message(user_id, "hello")
except PolisAuthError:
    print("Invalid bot token")
except PolisRateLimitError as e:
    print(f"Rate limited — retry after {e.retry_after}s")
except PolisApiError as e:
    print(f"HTTP {e.status_code}: {e.message}")
```

| Exception | HTTP Status |
|-----------|-------------|
| `PolisAuthError` | 401 |
| `PolisForbiddenError` | 403 |
| `PolisNotFoundError` | 404 |
| `PolisRateLimitError` | 429 |
| `PolisApiError` | Any non-2xx |

## Async Context Manager

```python
async with PolisBot("token") as bot:
    me = await bot.get_me()
    await bot.send_message(user_id, "Hello!")
# client is automatically closed
```

## Low-Level Polling

For full control over the update loop instead of using decorators:

```python
async with PolisBot("token") as bot:
    async for update in bot.poll(limit=20, timeout=30):
        print(update.type, update.user.display_name)

        if update.message and update.message.is_command:
            await bot.send_message(update.user.id, "Got your command!")
```

Updates are automatically acknowledged after each batch. Disable with `auto_ack=False`:

```python
async for update in bot.poll(auto_ack=False):
    # manually acknowledge
    await bot.acknowledge_updates([update.update_id])
```

## API Reference

### `PolisBot(token, base_url="https://polis.krlv.org", *, timeout=30.0)`

#### Properties

| Property | Description |
|----------|-------------|
| `me` | Cached `BotInfo` (available after `start()` / `run()`) |

#### Decorators

| Decorator | Description |
|-----------|-------------|
| `@bot.command(name, *, description=None)` | Register a `/command` handler with auto-parsed args |
| `@bot.on_message` | Handle non-command messages (text and/or file attachments) |
| `@bot.on_event(event_type)` | Handle lifecycle events (`BotStarted`, etc.) |
| `@bot.on_startup` | Run a coroutine once on startup |

#### Methods

| Method | Description |
|--------|-------------|
| `run(*, register_commands=True)` | Start polling and dispatch (blocking) |
| `start(*, register_commands=True)` | Async version of `run()` |
| `get_me()` | Get bot info |
| `send_message(user_id, content, *, reply_to_id=None)` | Send a text message |
| `send_file(user_id, file, *, filename=None, content_type=..., content=None)` | Upload a file (optionally with text) |
| `get_updates(*, limit, offset, timeout)` | Fetch pending updates |
| `acknowledge_updates(update_ids)` | Mark updates as delivered |
| `set_commands(commands)` | Register bot commands |
| `poll(*, limit=20, timeout=30, auto_ack=True)` | Async generator for long-polling |
| `close()` | Close the HTTP client |

#### Context Objects

| Class | Available On | Key Properties |
|-------|-------------|----------------|
| `CommandContext` | `@bot.command` | `user`, `message`, `args`, `bot`, `reply()`, `reply_file()` |
| `MessageContext` | `@bot.on_message` | Same as `CommandContext` |
| `EventContext` | `@bot.on_event` | `user`, `bot` |

#### `Attachment`

Each file attachment on `ctx.message.attachments` is an `Attachment` instance:

| Property / Method | Description |
|-------------------|-------------|
| `file_name` | Original filename |
| `content_type` | MIME type |
| `size_bytes` | File size in bytes |
| `id` | Server-assigned file ID |
| `download()` | Download and return raw `bytes` |
| `download_to(dest)` | Download and save to disk, returns `Path` |

## License

MIT
