Metadata-Version: 2.4
Name: vinrog
Version: 0.1.0
Summary: Official Python SDK for Vinrog bots (gateway, REST, interactions).
License: MIT
Project-URL: Homepage, https://github.com/PEXA47/Vinrog
Project-URL: Documentation, https://github.com/PEXA47/Vinrog/blob/main/protocol/gateway.md
Project-URL: Source, https://github.com/PEXA47/Vinrog/tree/main/packages/sdk-python
Keywords: vinrog,bot,gateway,websocket,chat
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Framework :: AsyncIO
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: aiohttp>=3.9
Provides-Extra: voice
Requires-Dist: livekit>=1.0; extra == "voice"
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Dynamic: license-file

# `vinrog`

Official Python SDK for Vinrog bots: gateway, REST, and interactions.

> Voice is included: `await client.join_voice(channel_id)` connects and `publish_pcm()` streams
> audio. LiveKit is an **optional extra**, so a text-only bot never installs it.

This package **implements** the protocol; it does not define it. The contract lives in
[`protocol/`](https://github.com/PEXA47/Vinrog/blob/main/protocol/gateway.md) and is shared with
the Rust server and the TypeScript SDK. If this README and `protocol/gateway.md` ever disagree,
`protocol/` wins.

## Install

```bash
pip install vinrog
```

## Usage

```python
import asyncio
import os

from vinrog import Client

client = Client(
    token=os.environ["BOT_TOKEN"],
    application_id=os.environ["APP_ID"],
    api_url="https://vinrog.example",
    intents=["GUILD_MESSAGES"],
)


@client.event
async def on_ready(ready):
    print(f"online as {ready.user_id}")


@client.event
async def on_message_create(message):
    if message.author_id == client.user:
        return  # ignore our own messages
    if message.content == "!ping":
        await message.reply("pong")


asyncio.run(client.login())
```

## Typed events, structures, builders

Every event the server dispatches is typed, and what arrives is an object with methods:

```python
from vinrog import ActionRowBuilder, ButtonBuilder, EmbedBuilder


@client.event
async def on_message_create(message):
    await message.react("👍")
    sent = await message.reply(
        embeds=[EmbedBuilder().set_title("Now playing").set_color("#5865f2")],
        components=[
            ActionRowBuilder().add_components(
                ButtonBuilder().set_style("primary").set_label("Skip").set_custom_id("skip")
            )
        ],
    )
    await sent.pin()


@client.event
async def on_member_join(member):
    await member.add_role(role_id)


@client.event
async def on_member_leave(leave):
    if leave.reason == "ban":
        audit(leave)
```

Names stay `snake_case` — idiomatic in Python and identical to the wire, so there is nothing to
convert. (The TypeScript SDK uses camelCase; that is the one place the two SDKs differ.)

Builders emit **Vinrog's** shape, not Discord's: components are tagged with the string
`"action_row"` / `"button"`, and styles are `"primary"` / `"secondary"` — not integers.

Two things the SDK deliberately does **not** do, because the server cannot support them:

- **`message.server_id` is `None` on `on_message_update`.** ``MESSAGE_UPDATE`` publishes the
  message flat and ``Message`` has no ``server_id``, so it is not on the wire — and with no cache
  there is nothing to synthesise it from. Tracked as a protocol gap, not papered over.
- **No per-route rate-limit buckets.** The server sends no ``X-RateLimit-*`` headers and no
  ``Retry-After``, so a client cannot model a budget it cannot observe. Instead the REST client
  keeps one request per route in flight (``serialize_by_route``, on by default), which bounds your
  own burst. That is politeness, and it is named as such.

Every structure method maps to a route in the Vinrog server that a bot token can call, checked
against ``server/src/routes/``. Structures are parsed from the canonical fixtures the server's own
tests deserialize, so a field rename fails a test rather than shipping.

`await client.login()` returns when the gateway is READY, and raises only on a *fatal* error — a
bad token, an unsupported protocol version, an invalid IDENTIFY. Transient failures are the SDK's
problem, not yours: it reconnects and resumes.

## What the SDK does for you

Reconnection is not a loop around `connect()` — it is a state machine, and most of it exists to
avoid losing events silently:

- **Resume with the *contiguous* sequence, not the highest seen.** If event 11 arrives before a
  replayed 10, acking 11 would drop 10 permanently with no error anywhere.
- **Bounded reordering during replay**, closed by `replay_through_seq` rather than a frame count
  (replayed and live frames are indistinguishable on the wire).
- **Zombie detection** from the age of the oldest unacknowledged heartbeat — receiving other
  events does *not* prove your side of the socket is alive.
- **`max(server hint, exponential backoff + jitter)`** on rate limits, so many bots limited at
  once don't reconnect as one synchronised wave.

Each is a rule in
[`protocol/gateway.md`](https://github.com/PEXA47/Vinrog/blob/main/protocol/gateway.md), verified
by the shared conformance suite — **the same 15 scenarios the TypeScript SDK passes**, run against
this package in CI.

## Interactions

`on_interaction_create` receives a parsed dataclass, discriminated on `type`:

```python
from vinrog import CommandInteraction


@client.event
async def on_interaction_create(interaction):
    if isinstance(interaction, CommandInteraction):
        await client.rest.reply_to_interaction(
            interaction.id, interaction.token, {"content": "pong"}, ephemeral=True
        )
```

The server sends four types — `command`, `component`, `modal_submit`, `autocomplete`. Handle the
ones you register for and ignore the rest. Note `values` is a `list` on a component (select-menu
picks) but a `dict` on a modal submit (field → value), which is why they are separate classes
rather than one.

## Voice

```python
voice = await client.join_voice(voice_channel_id)
async with voice:
    for frame in pcm_frames:  # s16le, 48 kHz stereo
        await voice.publish_pcm(frame)
```

Needs the optional extra:

```bash
pip install "vinrog[voice]"
```

Without it, `join_voice` raises `VoiceDependencyError` naming the extra and the install command —
a text-only bot never loads the native wheel, because the import is lazy.

**The connection is publish-only.** The server mints bots a token with
`can_subscribe = False`, so there is no inbound audio API here — the token forbids it.

**There is no `voice_join()`.** `POST /channels/{id}/voice/join` enforces `user_limit` and writes
`voice_channel_members` itself, but the bot's token is minted *capacity-bypassed* — calling it
would consume a slot the token was designed not to consume. Connecting to LiveKit **is** the join.

The rest of the voice surface is REST: `list_voice_members`, `list_active_voice`,
`set_self_voice_state`, and moderation (`move_voice_member`, `disconnect_voice_member`,
`set_server_mute`, `set_server_deafen`).

## Errors

`VinrogHTTPError` carries `status`, `code` and `body`. **`code` is `None` when the response had no
envelope** — normal, not exceptional: the per-IP rate limiter (429), request timeouts (408),
body-size caps (413) and unmatched routes (404) all return an empty body. Switch on `status` when
`code` is absent.

`GatewayError` carries `.code` — the close code, or the HTTP status for a handshake rejection.
`ProtocolError` is a subclass raised when the server sends something the contract forbids; the SDK
never papers over a missing REQUIRED field with a default.

## Typing

Fully annotated and PEP 561 compliant (`py.typed`), so `mypy` and `pyright` see the types.
