Metadata-Version: 2.4
Name: holt
Version: 0.1.1
Summary: Python client SDK for the Holt chat app bot API with end-to-end encryption
Author: Holt-Chat
License: MIT
Requires-Python: >=3.9
Requires-Dist: cryptography>=42
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Description-Content-Type: text/markdown

<div align="center">
  <img src="https://raw.githubusercontent.com/Holt-Chat/shore/main/media/holt.png" width="80" alt="Holt Chat logo">

  # Holt Python SDK

  Python client SDK for the [Holt](https://github.com/Holt-Chat) chat app bot API, with full end-to-end encryption.

  [![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](../LICENSE)
  [![PyPI](https://img.shields.io/badge/PyPI-holt-3776ab?style=flat-square)](https://pypi.org/project/holt/)
  [![Python](https://img.shields.io/badge/python-3.9%2B-3776ab?style=flat-square)](#)
</div>

---

## Installation

```bash
pip install holt
# or from source:
pip install -e /path/to/holt-sdk/python
```

## Quickstart

### Send a message

```python
from holt import HoltClient

# Your bot token (30-char string) and RSA-2048 private key as base64(PKCS8 DER)
BOT_TOKEN = "your-30-char-bot-token-here"
PRIVATE_KEY_B64 = "your-base64-pkcs8-der-private-key"

client = HoltClient(
    base_url="https://your-holt-instance.example.com",
    token=BOT_TOKEN,
    private_key_b64=PRIVATE_KEY_B64,
)

# Get bot info
me = client.get_me()
print("Logged in as:", me["username"])

# List channels
channels = client.get_channels()
for ch in channels:
    print(ch.id, ch.type, ch.name)

# Send a message (auto-detects channel type, handles E2EE)
result = client.send_message(channel_id="your-channel-id", text="Hello from the bot!")
print("Sent:", result.message_id)

# Get recent messages (auto-decrypts E2EE content)
messages = client.get_messages(channel_id="your-channel-id", limit=20)
for msg in messages:
    if msg.decrypt_error:
        print("[undecryptable]", msg.decrypt_error)
    else:
        print(f"{msg.user.username if msg.user else 'unknown'}: {msg.content}")

client.close()
```

### Listen for real-time messages and echo-reply

```python
from holt import HoltClient

BOT_TOKEN = "your-30-char-bot-token-here"
PRIVATE_KEY_B64 = "your-base64-pkcs8-der-private-key"

client = HoltClient(
    base_url="https://your-holt-instance.example.com",
    token=BOT_TOKEN,
    private_key_b64=PRIVATE_KEY_B64,
)

me = client.get_me()

@client.on("message_sent")
def on_message(payload):
    msg = payload["message"]
    channel_id = payload["channel_id"]
    # Ignore messages from the bot itself
    if msg.user and msg.user.username==me["username"]:
        return
    if msg.decrypt_error:
        print(f"[{channel_id}] Could not decrypt message from {msg.user.username if msg.user else '?'}")
        return
    print(f"[{channel_id}] {msg.user.username if msg.user else '?'}: {msg.content}")
    # Echo the message back
    client.send_message(channel_id, f"Echo: {msg.content}", reply_to=msg.id)

@client.on("typing")
def on_typing(payload):
    print(f"{payload.get('username')} is typing in {payload.get('channel_id')}")

print("Listening for events... (Ctrl-C to stop)")
client.run()  # blocks; connects SSE stream and dispatches events
```

### Create a DM / join via invite

```python
dm_channel_id = client.create_dm(username="alice")
client.send_message(dm_channel_id, "Hey Alice!")

channel_id = client.join_invite("invite-code-here")
print("Joined:", channel_id)
```

### Error handling

```python
from holt import HoltClient, HoltError

try:
    client.send_message("bad-channel", "hello")
except HoltError as e:
    print(f"HTTP {e.status}: {e.message}")
    if e.rate_limit_reset:
        print(f"Rate limited, reset at epoch: {e.rate_limit_reset}")
    print("Full body:", e.body)
```

## Buttons and modals

```python
from holt import button, action_row, text_input, BUTTON_STYLE_SUCCESS, BUTTON_STYLE_DANGER

client.send_message(channel_id, "Pick an option:", components=[
    action_row(
        button("Approve", custom_id="approve", style=BUTTON_STYLE_SUCCESS),
        button("Deny", custom_id="deny", style=BUTTON_STYLE_DANGER),
    )
])

@client.on("component_interaction")
def on_click(payload):
    if payload["custom_id"]=="deny":
        client.respond_with_modal(
            payload["interaction_id"],
            title="Denial reason",
            custom_id="deny_modal",
            components=[action_row(text_input("reason", "Why are you denying this?", style=2, max_length=500))],
        )
    else:
        client.ack_interaction(payload["interaction_id"])

@client.on("modal_submit")
def on_modal(payload):
    print("Reason:", payload["components"]["reason"])
```

Edit an existing message's buttons with `client.edit_message(channel_id, message_id, components=[...])`; pass `components=[]` to clear them, or omit the parameter to leave them unchanged.

## Slash commands

```python
client.set_commands([
    {
        "name": "announce",
        "description": "Post an announcement",
        "options": [
            {"name": "message", "description": "Text to post", "type": "string", "required": True, "max_length": 500}
        ],
    }
])

@client.on("interaction_create")
def on_command(payload):
    if payload["command"]=="announce":
        client.send_message(payload["channel_id"], payload["options"]["message"])
```

`set_commands` atomically replaces the bot's entire command set. Use `upsert_command`/`delete_command` to manage commands one at a time.

## Rich content blocks

Diagrams and sandboxed interactive widgets are just markers inside a message's text, built with helpers and appended to the content you send:

```python
from holt import diagram_block, interactive_block

client.send_message(channel_id, "Here's the flow:\n" + diagram_block("graph TD; A-->B;"))
client.send_message(channel_id, "Try it:\n" + interactive_block(html="<button>Click</button>", js="document.querySelector('button').onclick=()=>alert('hi')"))
```

## Embeds

`embed_block(**fields)` covers title, description, color, url, author `{name, url}`, footer `{text}`, fields `[{name, value, inline}]`, and timestamp. Images and thumbnails aren't plain URLs though — the server stores them as uploaded assets, so upload first and reference the returned asset id:

```python
from holt import embed_block

with open("chart.png", "rb") as f:
    asset = client.upload_embed_asset(channel_id, f.read(), filename="chart.png", content_type="image/png")

client.send_message(channel_id, embed_block(
    title="Weekly report",
    description="Traffic is up 12% week over week.",
    color=0x6f42c1,
    image={"id": asset["id"]},
))
```

Pass `encrypt=True` to `upload_embed_asset` when posting in a DM or encrypted group channel (broadcast channels are always plaintext, so this is ignored there).

## Crypto overview

- **AES-GCM-256**: messages in DM and encrypted group channels are encrypted with a shared per-channel AES key.
- **RSA-OAEP**: each member's copy of the AES key is wrapped with their RSA-2048 public key (OAEP with SHA-256 and label `parley`).
- **RSA-PSS**: every message is signed by the sender's private key so recipients can verify authenticity.
- The SDK handles all of this automatically inside `send_message`, `edit_message`, and `get_messages`.

## Channel types

| Value | Constant | Description |
|-------|----------|-------------|
| 1 | `CHANNEL_DM` | Direct message (E2EE) |
| 2 | `CHANNEL_ENCRYPTED_GROUP` | Encrypted group (E2EE) |
| 3 | `CHANNEL_BROADCAST` | Broadcast (plaintext) |

## License

MIT
