Skip to main content

Python client SDK for the Holt chat app bot API with end-to-end encryption

Project description

Holt Chat logo

Holt Python SDK

Python client SDK for the Holt chat app bot API, with full end-to-end encryption.

License: MIT PyPI Python


Installation

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

Quickstart

Send a message

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

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

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

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

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.

Reactions

reaction_id = client.add_reaction(channel_id, message_id, "👍")
client.remove_reaction(channel_id, message_id, reaction_id)

@client.on("reaction_add")
def on_reaction(payload):
    print(f"{payload['reaction']['user']['username']} reacted {payload['reaction']['content']}")

add_reaction handles encryption automatically for DM/group channels, the same way send_message does. A user (or bot) can add several distinct reactions to the same message; the same emoji added twice is only rejected server-side (409) in broadcast channels, since encrypted content can't be compared server-side — check message.reactions yourself before re-adding one you already sent. Each message caps out at 20 distinct reactions and 20 reactions from any one user.

Slash commands

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:

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:

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

holt-0.1.2.tar.gz (11.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

holt-0.1.2-py3-none-any.whl (12.0 kB view details)

Uploaded Python 3

File details

Details for the file holt-0.1.2.tar.gz.

File metadata

  • Download URL: holt-0.1.2.tar.gz
  • Upload date:
  • Size: 11.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for holt-0.1.2.tar.gz
Algorithm Hash digest
SHA256 da1dea8d90574994324bd6a71ebbc307a6493ba24ec55462cd246ccefc244b4f
MD5 5a769cdb19ab2f0558d6b4418789b099
BLAKE2b-256 60cfd0c02e38032b00ed6148f65fd7b4b1ee12f1b67f9ddbc7f5427756e2e518

See more details on using hashes here.

File details

Details for the file holt-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: holt-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 12.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for holt-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 e522778f5ce60c71bab361437bef997905743201c9e9a93210805f8ffc9d9bbd
MD5 a21b859d3d6272a055c67273036ad39a
BLAKE2b-256 9bf114821888fda6858f84726305236dfb20f0924c5293ec0539362e243780d6

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page