Metadata-Version: 2.4
Name: banterbotapi
Version: 1.0.22
Summary: Python SDK for building bots on Banter (banterchat.org) — discord.py-style.
Author-email: Banter <contact@banterchat.org>
License-Expression: MIT
Project-URL: Homepage, https://banterchat.org
Project-URL: Repository, https://banterchat.org
Keywords: banter,bot,chat,sdk,discord-py-style
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: aiohttp>=3.9
Requires-Dist: websockets>=12
Dynamic: license-file

# banterbotapi

Python SDK for building bots on [Banter](https://banterchat.org).

```bash
pip install banterbotapi
```

Install name is `banterbotapi`, import name is `banterapi` (same pattern as Pillow/PIL).

## Quick start

```python
from banterapi import Bot, Intents

bot = Bot(intents=Intents.default() | Intents.MESSAGE_CONTENT)

@bot.event
async def on_ready():
    print(f"logged in as {bot.user.username}")

@bot.event
async def on_message(message):
    if message.content == "!ping":
        await message.reply("pong")

bot.run("YOUR_BOT_TOKEN")
```

`bot.run()` blocks until disconnect. Ctrl-C is handled cleanly.

## Prefix commands

```python
@bot.command()
async def echo(ctx, *, text: str):
    await ctx.reply(text)

@bot.command(name="add", help="Add two numbers.")
async def add(ctx, a: int, b: int):
    await ctx.reply(str(a + b))
```

Type annotations drive arg parsing (`int`, `float`, `bool`). `*args` collects the rest, `*, text: str` consumes the remainder verbatim. Default prefix is `!`; pass `command_prefix="?"` to `Bot()` to change it.

## Slash commands

```python
from banterapi import SlashOption, Optional, OPTION_STRING, OPTION_INTEGER

@bot.slash_command(
    name="roll",
    description="Roll an N-sided die",
    options=[Optional("sides", type=OPTION_INTEGER, description="default 6")],
)
async def roll(interaction):
    sides = interaction.options.get("sides", 6)
    await interaction.respond(f"🎲 {sides}-sided die")
```

`Optional(name, ...)` is shorthand for `SlashOption(name, ..., required=False)`.

Option types: `OPTION_STRING`, `OPTION_INTEGER`, `OPTION_BOOLEAN`, `OPTION_USER`, `OPTION_CHANNEL`, `OPTION_ROLE`.

Slash command handlers take a single `Interaction`. Response methods:

| Method | Use for |
|---|---|
| `respond(content, embed=, ephemeral=, components=)` | The visible reply. Once per interaction. |
| `defer(ephemeral=)` | Ack now, reply within 15 minutes. Shows a thinking indicator. |
| `followup(content, embed=, ...)` | Additional messages after `respond`. Repeatable. |
| `update(content, embed=, components=)` | Button only. Edits the source message in place. |

`ephemeral=True` makes the message visible only to the invoker.

The SDK calls `bot.sync_commands()` automatically on `on_ready` so registered slash commands appear in autocomplete immediately. Two bots in the same guild can register the same command name; the server dispatches the click to the correct bot via the application id.

## Buttons

```python
from banterapi import Embed

@bot.slash_command(name="confirm", description="Confirm or cancel")
async def confirm(interaction):
    embed = Embed(title="Are you sure?")
    embed.add_button("Yes", style="success", custom_id="confirm_yes")
    embed.add_button("No",  style="danger",  custom_id="confirm_no")
    await interaction.respond(embed=embed, ephemeral=True)

@bot.on_button("confirm_yes")
async def yes(interaction):
    await interaction.update(content="Confirmed.", components=[])

@bot.on_button("confirm_no")
async def no(interaction):
    await interaction.update(content="Cancelled.", components=[])
```

Match a family of buttons with a trailing `*` — useful for pagination where the page rides in the id:

```python
@bot.on_button("page_*")
async def page(interaction):
    n = int(interaction.custom_id.removeprefix("page_"))
    await interaction.update(embed=build_page(n))
```

Button styles: `primary`, `secondary`, `success`, `danger`, `link`. `link` requires `url`; everything else needs `custom_id` to fire a handler. Up to 5 buttons per row, up to 5 rows per message.

Buttons work everywhere a message goes: `bot.send_message(...)`, `message.reply(...)`, `interaction.respond(...)`, `interaction.followup(...)`, `interaction.update(...)`.

## Embeds

```python
embed = Embed(title="Status", description="All systems nominal.", color=0x57F287)
embed.add_field("Uptime", "12d 4h", inline=True)
embed.add_field("Users",  "1,204",   inline=True)
embed.set_footer("Last checked just now")
embed.set_thumbnail("https://example.com/icon.png")
await message.channel.send(embed=embed)
```

Color accepts an int (`0x5865F2`) or a CSS hex string (`"#5865f2"`).

## Events

```python
@bot.event
async def on_ready(): ...

@bot.event
async def on_message(message): ...

@bot.event
async def on_message_edit(payload): ...

@bot.event
async def on_message_delete(payload): ...

@bot.event
async def on_reaction_add(payload): ...

@bot.event
async def on_reaction_remove(payload): ...

@bot.event
async def on_member_join(payload): ...

@bot.event
async def on_member_remove(payload): ...

@bot.event
async def on_interaction(interaction): ...
```

Each event handler receives one positional argument — typically a model (`Message`, `Interaction`) or the raw event dict. For events that haven't been wrapped in a model yet, the dict has actor pair fields (`actor_type`, `actor_id`) plus legacy aliases (`user_id`, `is_bot`) so handlers reading either form work.

Handlers may be sync or async. Exceptions inside a handler are logged and swallowed.

## Permissions

```python
from banterapi import Permissions, has_permissions, MissingPermissions

@bot.command()
@has_permissions(Permissions.BAN_MEMBERS)
async def ban(ctx, target: str):
    ...

@bot.event
async def on_command_error(ctx, exc):
    if isinstance(exc, MissingPermissions):
        await ctx.reply("you can't do that")
```

`Permissions` is a flag class — combine with `|`, check with `has_perm(mask, required)`. The `Administrator` bit bypasses individual perm checks.

Constants: `SEND_MESSAGES`, `MANAGE_CHANNELS`, `MANAGE_ROLES`, `MANAGE_MESSAGES`, `ADMINISTRATOR`, `MENTION_EVERYONE`, `VIEW_CHANNELS`, `ATTACH_FILES`, `BAN_MEMBERS`, `USE_SLASH_COMMANDS`, `MANAGE_GUILD`, `KICK_MEMBERS`.

`@has_permissions(...)` raises `MissingPermissions` before the command body runs — catch via `on_command_error` (or `on_error` for slash commands).

## Intents

```python
intents = Intents.default() | Intents.MESSAGE_CONTENT
bot = Bot(intents=intents)
```

`Intents.default()` covers the common cases (guilds, members, messages without content, reactions, bot events). Add `MESSAGE_CONTENT` to receive message text in `on_message`.

Intent constants: `GUILDS`, `GUILD_MEMBERS`, `GUILD_MODERATION`, `GUILD_PRESENCES`, `GUILD_MESSAGES`, `GUILD_REACTIONS`, `GUILD_TYPING`, `GUILD_VOICE_STATES`, `DIRECT_MESSAGES`, `DIRECT_REACTIONS`, `DIRECT_TYPING`, `MESSAGE_CONTENT`, `BOT_EVENTS`. Use `Intents.all()` for all of them or `Intents.none()` to start from zero.

## Sending files

```python
from banterapi import File

await message.channel.send(content="Here's the report.", file=File("report.pdf"))
await message.channel.send(files=[File("a.png"), File("b.png")])
```

`File` accepts a path, a file-like with `.read()`, or `bytes` (with `filename=`).

## Bot identity in messages

```python
@bot.event
async def on_message(message):
    if message.actor_type == "bot":
        return  # ignore other bots
    if message.user_id == bot.user.id:
        return  # ignore self
```

Messages carry both the new actor pair (`message.actor_type`, `message.actor_id`) and the legacy alias (`message.user_id` is the same value as `actor_id`). Use whichever you prefer; new code should branch on `actor_type` to distinguish bot-authored messages.

`bot.process_commands()` already skips bot-authored messages by default — including the bot's own messages — so prefix commands won't trigger on bot output.

## Error handling

```python
from banterapi import Forbidden, NotFound, RateLimited

try:
    await message.channel.send("hi")
except Forbidden:
    pass  # bot lacks permission
except RateLimited as e:
    await asyncio.sleep(e.retry_after)
```

Exception hierarchy:

- `BanterError` — base. Catch all.
- `HTTPException` — any non-2xx REST response. Has `.status`, `.code`, `.message`.
  - `Forbidden` — 403
  - `NotFound` — 404
  - `RateLimited` — 429. Has `.retry_after`.
- `GatewayError` — websocket-level failure. Usually transient; the bot reconnects.
- `LoginFailure` — auth rejected. Terminal — rotate the token.
- `MissingPermissions` — raised by `@has_permissions` decorator.

## Bot methods

The `bot` object exposes wrappers around the REST API. All are async; all return models or raise.

| Method | Description |
|---|---|
| `send_message(channel_id, content="", embed=, reply_to=, file=, files=, components=)` | Send a message. |
| `get_user(user_id)` | Fetch a user. |
| `get_guild(guild_id)` | Fetch a guild. |
| `get_member(guild_id, user_id)` | Fetch a guild member. |
| `edit_guild(guild_id, *, name=, description=, welcome_channel_id=)` | Edit a guild. |
| `list_channels(guild_id)` / `get_channel(channel_id)` | Channel reads. |
| `create_channel(guild_id, name, **kw)` / `edit_channel(channel_id, **patch)` / `delete_channel(channel_id)` | Channel CRUD. |
| `reorder_channels(guild_id, items)` | Bulk reorder. |
| `purge_channel(channel_id, limit=100)` | Bulk-delete recent messages. |
| `set_channel_permissions(channel_id, role_id, *, allow=, deny=)` | Channel role override. |
| `list_roles(guild_id)` / `everyone_role(guild_id)` | Role reads. |
| `create_role(guild_id, name, **kw)` | Create a role. |
| `list_categories(guild_id)` / `create_category(...)` / `edit_category(...)` / `delete_category(...)` | Category CRUD. |
| `reorder_categories(guild_id, items)` | Bulk reorder. |
| `set_category_permissions(category_id, role_id, *, allow=, deny=)` | Category role override. |
| `create_task(coro)` | Schedule a background task on the bot's event loop. |

Models (`Message`, `Channel`, `Member`) carry their own bound helpers (`message.reply()`, `channel.send()`, etc.) when constructed with a client — those go through the same HTTP path.
