Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,19 @@ No linter is configured. Commit style: conventional commits (`feat:`, `fix:`, `d

**Entry point:** `src/discli/cli.py` → Click root group → registers all command groups.

**Core flow:** Click CLI → permission/audit check (security.py) → `run_discord()` (client.py) → async discord.py action → `output()` (utils.py).
**Core flow:** Click CLI → permission/audit check (security.py) → `run_rest()` for one-shot HTTP actions or `run_gateway()` for live state → async discord.py action → `output()` (utils.py).

**Key modules:**
- `client.py` — Token resolution (flag → env var → config file), `run_discord()` sync wrapper around async Discord actions
- `client.py` — Token resolution, REST/Gateway lifecycle separation, and minimal Gateway intent construction
- `security.py` — Permission profiles (full/chat/readonly/moderation/voice/interact), audit logging to `~/.discli/audit.log` (JSONL), token-bucket rate limiter
- `utils.py` — Output formatting (text/JSON via `--json` flag), channel/guild resolvers
- `utils.py` — Output formatting plus async, cache-first resource resolvers with REST fallbacks
- `config.py` — Token storage at `~/.discli/config.json`
- `voice_engine.py` — VoiceEngine with AudioPlayer, AudioListener, VAD-based speech segmentation, audio codec handling
- `interact_engine.py` — InteractEngine for modals, workflows, and dashboards with interaction routing and state management
- `tts.py` — TTS provider protocol with ElevenLabs and OpenAI implementations
- `stt.py` — STT provider protocol with Deepgram and OpenAI Whisper implementations

**Command pattern:** Each module in `commands/` defines Click commands, takes an async action function, and calls `run_discord(ctx, action)`. Follow existing modules when adding new commands.
**Command pattern:** Each module in `commands/` defines Click commands and an async action. One-shot commands call `run_rest(ctx, action)`. Only live events, voice state, and persistent connections call `run_gateway(ctx, action, features=...)` or manage a long-lived Gateway client directly.

**`serve` command (commands/serve.py):** The largest module (~1200 lines). Runs a persistent bot with bidirectional JSONL over stdin/stdout. Features: event forwarding, action dispatch (send/reply/edit/delete/stream/typing/reactions/threads/polls/channels/members/roles/DMs), voice actions (connect/speak/play/listen/etc.), interactive actions (workflows, dashboards), slash command registration, streaming message edits with periodic flush, Windows-compatible stdin reading via threading.

Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ discli serve

Every command has a `--json` flag for scripts. Identifiers accept both names and IDs (`#general` or `123456789`, `alice` or her snowflake). One token, one CLI, no boilerplate.

## Connection model

One-shot commands use Discord's HTTP API and do not open a Gateway session or consume an `IDENTIFY`. This includes message, reaction, channel, role, member, thread, poll, webhook, event, DM, and typing commands.

Gateway connections are reserved for features that need live Discord state: `listen`, `serve`, and voice commands. These commands request only the intents required by their selected event types; discli does not use `Intents.all()`.

Some Discord HTTP endpoints and response fields are still restricted by application settings. For example, listing all guild members can require Server Members Intent, and reading arbitrary message content can require Message Content Intent. Those restrictions do not cause one-shot commands to open a Gateway connection.

## What you can do

- **Messages**: send, edit, delete, search, history, embeds, attachments, replies
Expand Down Expand Up @@ -70,7 +78,7 @@ You'll also need **libopus** (`apt install libopus0` / `brew install opus`) and
## Setup

1. Create a bot at [Discord Developer Portal](https://discord.com/developers/applications).
2. Enable the intents you need (Message Content for reading messages, Members for member lookups, Voice State if you'll touch voice). Pick least-privilege; don't enable everything.
2. Enable privileged intents only when a selected feature needs them (Message Content for live message content, Members for member lists or name lookups). Standard intents such as Voice States are requested automatically by the relevant Gateway command.
3. Invite the bot to your server with the permissions you actually need.
4. Save your token:

Expand Down
3 changes: 2 additions & 1 deletion agents/discord-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ discli server info "server name"

### Roles
```bash
discli role list "server name"
discli role list "server name" # member counts omitted by default
discli role list "server name" --with-member-counts # compute per-role member counts
discli role create "server name" "role-name" --color ff0000 --permissions 8
discli role delete "server name" <role>
discli role edit "server name" "Role" --name "New Name" --color 00ff00 --hoist --mentionable
Expand Down
18 changes: 8 additions & 10 deletions docs/architecture/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ flowchart LR
2. Click parses arguments and global options (`--token`, `--json`, `--yes`, `--profile`).
3. The active permission profile is checked to confirm the command is allowed.
4. The bot token is resolved from flag, environment variable, or config file.
5. A temporary `discord.Client` connects, executes the action on `on_ready`, and disconnects.
5. One-shot commands authenticate an HTTP client and execute directly. Live commands connect a Gateway client with feature-specific intents.
6. The Discord API processes the request.

## Module dependency graph
Expand Down Expand Up @@ -57,18 +57,18 @@ flowchart TD
| Module | File | Purpose |
|---|---|---|
| **CLI entry point** | `cli.py` | Click root group. Registers all command groups, defines global options, hosts `permission` and `audit` subcommands. |
| **Commands** | `commands/*.py` | Each file defines a Click command group (e.g., `message`, `channel`, `role`). Commands build an async action function and hand it to `run_discord()`. |
| **Client** | `client.py` | `resolve_token()` resolves the bot token. `run_discord()` checks permissions, creates a temporary `discord.Client`, runs the action on `on_ready`, and tears down the connection. |
| **Commands** | `commands/*.py` | Each file defines a Click command group. One-shot actions use `run_rest()`; live state and voice actions use the Gateway path. |
| **Client** | `client.py` | Resolves tokens, separates REST and Gateway lifecycles, enforces permissions, and builds the minimum intents for Gateway features. |
| **Security** | `security.py` | Permission profiles (allow/deny lists), `is_command_allowed()` enforcement, `audit_log()` JSONL writer, `RateLimiter` (token bucket), `check_user_permission()` for Discord-level permission checks, destructive action confirmation. |
| **Config** | `config.py` | Reads/writes `~/.discli/config.json`. Handles token persistence and config merging. |
| **Utils** | `utils.py` | `output()` respects the `--json` flag. `resolve_channel()` and `resolve_guild()` translate names/IDs to discord.py objects. |
| **Utils** | `utils.py` | `output()` respects the `--json` flag. Async resource resolvers translate names/IDs with cache-first lookups and REST fallbacks. |
| **Serve** | `commands/serve.py` | Persistent bot mode. Reads JSONL from stdin, writes JSONL to stdout. ~1100 lines covering 25+ actions, event forwarding, slash command registration, and streaming with 1.5s flush intervals. |

## Two operating modes

### Mode 1: Fire-and-forget CLI commands

This is the default mode. Each command creates a short-lived `discord.Client`, performs one operation, and exits.
This is the default mode. Each command creates a short-lived HTTP-authenticated `discord.Client`, performs one operation, and exits without opening a Gateway session.

```mermaid
sequenceDiagram
Expand All @@ -79,16 +79,14 @@ sequenceDiagram

User->>CLI: discli message send "#general" "Hello"
CLI->>CLI: Parse args, check permissions, resolve token
CLI->>Client: Create client, connect
Client->>API: Gateway connect
API-->>Client: on_ready
Client->>API: Send message
CLI->>Client: Create client and authenticate token
Client->>API: HTTP request to send message
API-->>Client: 200 OK
Client->>Client: Close connection
CLI-->>User: "Message sent to #general"
```

This approach is simple and stateless. The connection lives only as long as the single API call takes. The trade-off is connection overhead on every invocation (typically 1-2 seconds for the gateway handshake).
This approach is simple and stateless. It is subject to Discord HTTP rate limits, but it does not send Gateway `IDENTIFY`, consume session-start quota, or require unrelated Gateway intents.

**Best for:** scripting, cron jobs, one-off operations, piping into other tools.

Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started/your-first-bot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ This guide walks you through creating a Discord application, inviting your bot t
</Callout>
</Step>

<Step title="Enable the MESSAGE CONTENT intent">
<Step title="Enable MESSAGE CONTENT for live message events">
On the same **Bot** settings page, scroll down to **Privileged Gateway Intents**.

Enable **MESSAGE CONTENT INTENT**. This allows your bot to read the contents of messages sent in servers.
Enable **MESSAGE CONTENT INTENT** if you will use `discli listen`, `discli serve`, or another feature that consumes live message events. One-shot REST commands do not open a Gateway session, although Discord may still restrict message-content fields on HTTP responses.

Click **Save Changes**.

Expand Down
8 changes: 4 additions & 4 deletions docs/guides/building-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -863,12 +863,12 @@ See [`agents/ai_serve_agent.py`](/agents/ai_serve_agent.py) for the full impleme
</Card>
</CardGroup>

## Architecture Decision: listen vs serve
## Architecture Decision: standalone CLI vs serve

| Feature | `discli listen` (Levels 1-2) | `discli serve` (Levels 3-5) |
| Feature | Standalone CLI (Levels 1-2) | `discli serve` (Levels 3-5) |
|---------|------------------------------|------------------------------|
| Connection | New connection per action | Single persistent connection |
| Latency | 2-3s per action | Under 100ms per action |
| Connection | One HTTP lifecycle per action | Single persistent Gateway connection |
| Latency | HTTP request latency per action | Under 100ms per action |
| Streaming | Not possible | Full support |
| Threads | Can create (via CLI) | Can create and manage |
| Typing indicator | Not practical | Full support |
Expand Down
8 changes: 6 additions & 2 deletions docs/reference/cli-commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -505,16 +505,20 @@ Manage server roles.

### role list

List roles in a server.
List roles in a server. Per-role member counts are omitted by default (reported as `unavailable`) because computing them iterates the entire member list, which is slow and rate-limit-prone on large servers and requires the privileged Server Members intent. Pass `--with-member-counts` to compute them.

```
discli role list <server>
discli role list <server> [OPTIONS]
```

| Argument | Required | Description |
|----------|----------|-------------|
| `SERVER` | yes | Server name or ID |

| Option | Required | Description |
|--------|----------|-------------|
| `--with-member-counts` | no | Compute per-role member counts. Off by default (Recommended) |

### role create

Create a role.
Expand Down
5 changes: 3 additions & 2 deletions docs/reference/serve-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1122,12 +1122,12 @@ Timeout a member, preventing them from sending messages or joining voice.

### role_list

List roles in a server.
List roles in a server. Per-role `members` counts are `null` by default because computing them iterates the entire member list (slow and rate-limit-prone on large servers, requires the privileged Server Members intent). Send `"with_member_counts": true` to compute them; if the bot lacks the Server Members intent the counts come back as `null` (unavailable) instead of `0`.

**Request:**

```json
{"action": "role_list", "guild_id": "123456", "req_id": "90"}
{"action": "role_list", "guild_id": "123456", "with_member_counts": true, "req_id": "90"}
```

**Response:**
Expand All @@ -1146,6 +1146,7 @@ List roles in a server.
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `guild_id` | yes | string | Server ID |
| `with_member_counts` | no | boolean | Compute per-role member counts. Default `false` (counts returned as `null`) |
| `req_id` | no | string | Correlation ID |

### role_assign
Expand Down
122 changes: 108 additions & 14 deletions src/discli/client.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import asyncio
from collections.abc import Iterable
from typing import Any, Callable, Coroutine

import click
import discord


Action = Callable[[discord.Client], Coroutine[Any, Any, Any]]

EVENT_FEATURES = {
"messages": "messages",
"edits": "messages",
"deletes": "messages",
"reactions": "reactions",
"members": "members",
"voice": "voice",
}


def resolve_token(token: str | None, config: dict) -> str:
if token:
return token
Expand All @@ -16,9 +29,53 @@ def resolve_token(token: str | None, config: dict) -> str:
)


async def run_action(token: str, action: Callable[[discord.Client], Coroutine[Any, Any, Any]]) -> Any:
"""Connect a bot client, run an action on_ready, return the result, disconnect."""
intents = discord.Intents.all()
def build_gateway_intents(features: Iterable[str] = ()) -> discord.Intents:
"""Build the smallest Gateway intent set needed for selected features."""
selected = set(features)
intents = discord.Intents.none()
intents.guilds = True

if "messages" in selected:
intents.guild_messages = True
intents.dm_messages = True
intents.message_content = True
if "reactions" in selected:
# discord.py's cached reaction events need message create events to
# populate the message cache, but they do not need message content.
intents.guild_messages = True
intents.dm_messages = True
intents.guild_reactions = True
intents.dm_reactions = True
if "members" in selected:
intents.members = True
if "voice" in selected:
intents.voice_states = True

return intents


def gateway_features_for_events(events: set[str] | None) -> set[str]:
"""Translate listen/serve event filters into Gateway feature names."""
selected_events = events if events is not None else set(EVENT_FEATURES)
return {EVENT_FEATURES[event] for event in selected_events if event in EVENT_FEATURES}


async def run_rest_action(token: str, action: Action) -> Any:
"""Authenticate for HTTP operations without opening a Gateway session."""
client = discord.Client(intents=discord.Intents.none())
try:
await client.login(token)
return await action(client)
finally:
await client.close()


async def run_gateway_action(
token: str,
action: Action,
intents: discord.Intents,
) -> Any:
"""Open one Gateway session, run an action after Ready, then disconnect."""
client = discord.Client(intents=intents)
result = None
error = None
Expand All @@ -28,23 +85,26 @@ async def on_ready():
nonlocal result, error
try:
result = await action(client)
except Exception as e:
error = e
except Exception as exc:
error = exc
finally:
await client.close()

await client.start(token)
try:
await client.start(token)
finally:
if not client.is_closed():
await client.close()

if error:
raise error
return result


def run_discord(ctx, action: Callable[[discord.Client], Coroutine[Any, Any, Any]]) -> Any:
"""Synchronous entry point: resolve token, check permissions, run action, return result."""
# Check permission profile with full command path (e.g. "message send")
def _check_permission(ctx: click.Context) -> None:
from discli.security import is_command_allowed
command_path = ctx.command_path.removeprefix("main ") # "discli message send" → "message send"

command_path = ctx.command_path.removeprefix("main ")
if command_path.startswith("discli "):
command_path = command_path[7:]
profile = ctx.obj.get("profile")
Expand All @@ -54,10 +114,44 @@ def run_discord(ctx, action: Callable[[discord.Client], Coroutine[Any, Any, Any]
"Run 'discli permission show' to see active profile."
)

token = resolve_token(ctx.obj.get("token"), {})

def _run(ctx: click.Context, operation: Coroutine[Any, Any, Any]) -> Any:
try:
return asyncio.run(run_action(token, action))
return asyncio.run(operation)
except discord.LoginFailure:
raise click.ClickException("Invalid bot token.")
except discord.HTTPException as e:
raise click.ClickException(f"Discord API error: {e}")
except discord.PrivilegedIntentsRequired:
raise click.ClickException(
"Discord rejected a required privileged Gateway intent. Enable only the intent "
"needed by this Gateway command in Developer Portal > Bot > Privileged Gateway Intents."
)
except discord.Forbidden as exc:
raise click.ClickException(f"Discord API permission denied: {exc}")
except discord.NotFound as exc:
raise click.ClickException(f"Discord resource not found: {exc}")
except discord.HTTPException as exc:
raise click.ClickException(f"Discord API error: {exc}")


def run_rest(ctx: click.Context, action: Action) -> Any:
"""Run a one-shot Discord HTTP action without connecting to Gateway."""
_check_permission(ctx)
token = resolve_token(ctx.obj.get("token"), {})
return _run(ctx, run_rest_action(token, action))


def run_gateway(
ctx: click.Context,
action: Action,
*,
features: Iterable[str] = (),
) -> Any:
"""Run a one-shot action that genuinely requires Gateway state."""
_check_permission(ctx)
token = resolve_token(ctx.obj.get("token"), {})
intents = build_gateway_intents(features)
return _run(ctx, run_gateway_action(token, action, intents))


# Backward compatibility for external imports. One-shot commands are REST by default.
run_discord = run_rest
Loading