From ac5ba41a76b08b71c9570a23ddeea1651ebfedcf Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 21 Jul 2026 02:18:58 +0800 Subject: [PATCH 1/2] fix(client): separate REST and Gateway lifecycles Run one-shot commands through Client.login() so REST operations no longer open a Gateway connection or request unrelated privileged intents. Reserve Gateway startup for voice and persistent event consumers, and derive intents from the selected event features. Convert resource resolvers to asynchronous cache-first lookups with HTTP fallbacks. Consolidate duplicated member, role, user, and thread resolution, preserve ID-based access without privileged intents, and explain when name lookup requires the Server Members intent. Close clients after actions and Gateway startup failures. Update listen and serve to use scoped intents and REST fallbacks for missing cache entries, and document the revised connection model. Add fake-client coverage for lifecycle cleanup, resolver behavior, and reaction list execution without a Gateway session. Refs: #24 --- CLAUDE.md | 8 +- README.md | 10 +- docs/architecture/overview.mdx | 18 +- docs/getting-started/your-first-bot.mdx | 4 +- docs/guides/building-agents.mdx | 8 +- src/discli/client.py | 122 ++++++++++-- src/discli/commands/channel.py | 70 +++---- src/discli/commands/dm.py | 28 +-- src/discli/commands/event.py | 22 +-- src/discli/commands/interact.py | 18 +- src/discli/commands/listen.py | 22 ++- src/discli/commands/member.py | 54 ++---- src/discli/commands/message.py | 38 ++-- src/discli/commands/poll.py | 14 +- src/discli/commands/reaction.py | 18 +- src/discli/commands/role.py | 95 +++++----- src/discli/commands/serve.py | 195 ++++++++++++------- src/discli/commands/server.py | 36 ++-- src/discli/commands/thread.py | 66 +++---- src/discli/commands/typing_cmd.py | 6 +- src/discli/commands/voice.py | 50 +++-- src/discli/commands/webhook.py | 14 +- src/discli/utils.py | 237 +++++++++++++++++++++--- tests/test_client.py | 182 +++++++++++++++++- tests/test_rest_commands.py | 44 +++++ tests/test_utils.py | 176 +++++++++++++++++- 26 files changed, 1136 insertions(+), 419 deletions(-) create mode 100644 tests/test_rest_commands.py diff --git a/CLAUDE.md b/CLAUDE.md index 0eb65df..477026d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index b6fb5d5..df42a36 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/docs/architecture/overview.mdx b/docs/architecture/overview.mdx index d66c236..e9cbb41 100644 --- a/docs/architecture/overview.mdx +++ b/docs/architecture/overview.mdx @@ -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 @@ -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 @@ -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. diff --git a/docs/getting-started/your-first-bot.mdx b/docs/getting-started/your-first-bot.mdx index 7f1f837..1eae26a 100644 --- a/docs/getting-started/your-first-bot.mdx +++ b/docs/getting-started/your-first-bot.mdx @@ -34,10 +34,10 @@ This guide walks you through creating a Discord application, inviting your bot t - + 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**. diff --git a/docs/guides/building-agents.mdx b/docs/guides/building-agents.mdx index ce8f86a..001f5c8 100644 --- a/docs/guides/building-agents.mdx +++ b/docs/guides/building-agents.mdx @@ -863,12 +863,12 @@ See [`agents/ai_serve_agent.py`](/agents/ai_serve_agent.py) for the full impleme -## 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 | diff --git a/src/discli/client.py b/src/discli/client.py index a3066d0..52f9dae 100644 --- a/src/discli/client.py +++ b/src/discli/client.py @@ -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 @@ -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 @@ -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") @@ -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 diff --git a/src/discli/commands/channel.py b/src/discli/commands/channel.py index 8a630ee..42fc859 100644 --- a/src/discli/commands/channel.py +++ b/src/discli/commands/channel.py @@ -1,8 +1,15 @@ import click import discord -from discli.client import run_discord -from discli.utils import output, resolve_channel, resolve_guild +from discli.client import run_rest +from discli.utils import ( + fetch_guilds, + output, + resolve_channel, + resolve_guild, + resolve_member, + resolve_role, +) @click.group("channel") @@ -19,12 +26,12 @@ def channel_list(ctx, server): def action(client): async def _action(client): if server: - guilds = [resolve_guild(client, server)] + guilds = [await resolve_guild(client, server)] else: - guilds = client.guilds + guilds = await fetch_guilds(client) channels = [] for g in guilds: - for ch in g.channels: + for ch in await g.fetch_channels(): if isinstance(ch, (discord.TextChannel, discord.VoiceChannel, discord.ForumChannel)): channels.append({ "id": str(ch.id), @@ -36,7 +43,7 @@ async def _action(client): output(ctx, channels, plain_text="\n".join(plain_lines) if plain_lines else "No channels found.") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @channel_group.command("create") @@ -50,7 +57,7 @@ def channel_create(ctx, server, name, channel_type, topic): def action(client): async def _action(client): - guild = resolve_guild(client, server) + guild = await resolve_guild(client, server) if channel_type == "text": ch = await guild.create_text_channel(name, topic=topic) elif channel_type == "voice": @@ -63,7 +70,7 @@ async def _action(client): output(ctx, data, plain_text=f"Created #{ch.name} (ID: {ch.id})") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @channel_group.command("delete") @@ -76,14 +83,14 @@ def channel_delete(ctx, channel): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) name = ch.name await ch.delete() audit_log("channel delete", {"channel": name}) output(ctx, {"id": str(ch.id), "deleted": True}, plain_text=f"Deleted #{name}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @channel_group.command("info") @@ -94,12 +101,13 @@ def channel_info(ctx, channel): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) + guild = await client.fetch_guild(ch.guild.id) data = { "id": str(ch.id), "name": ch.name, "type": str(ch.type), - "server": ch.guild.name, + "server": guild.name, "topic": getattr(ch, "topic", None), "created_at": ch.created_at.isoformat(), } @@ -107,7 +115,7 @@ async def _action(client): output(ctx, data, plain_text="\n".join(plain_lines)) return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @channel_group.command("edit") @@ -122,7 +130,7 @@ def channel_edit(ctx, channel, name, topic, slowmode, nsfw): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) kwargs = {} if name is not None: kwargs["name"] = name @@ -139,7 +147,7 @@ async def _action(client): output(ctx, data, plain_text=f"Updated #{ch.name}: {', '.join(kwargs.keys())}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @channel_group.command("forum-post") @@ -152,7 +160,7 @@ def channel_forum_post(ctx, channel, title, content, files): """Create a post in a forum channel.""" def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) if not isinstance(ch, discord.ForumChannel): raise click.ClickException(f"#{ch.name} is not a forum channel") attachments = [discord.File(f) for f in files] @@ -168,7 +176,7 @@ async def _action(client): } output(ctx, data, plain_text=f"Created forum post '{title}' in #{ch.name}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @channel_group.command("set-permissions") @@ -182,32 +190,12 @@ def channel_set_permissions(ctx, channel, target, allow, deny, target_type): """Set permission overwrites for a role or member on a channel.""" def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) guild = ch.guild if target_type == "role": - obj = None - try: - role_id = int(target) - obj = guild.get_role(role_id) - except ValueError: - for role in guild.roles: - if role.name.lower() == target.lower(): - obj = role - break - if not obj: - raise click.ClickException(f"Role not found: {target}") + obj = await resolve_role(guild, target) else: - obj = None - try: - member_id = int(target) - obj = guild.get_member(member_id) - except ValueError: - for member in guild.members: - if member.name.lower() == target.lower(): - obj = member - break - if not obj: - raise click.ClickException(f"Member not found: {target}") + obj = await resolve_member(guild, target) overwrite = ch.overwrites_for(obj) valid_perms = {p for p, _ in discord.Permissions()} if allow: @@ -226,4 +214,4 @@ async def _action(client): data = {"channel": ch.name, "target": str(obj), "allow": allow, "deny": deny} output(ctx, data, plain_text=f"Updated permissions for {obj} on #{ch.name}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/dm.py b/src/discli/commands/dm.py index 3425618..b3868e4 100644 --- a/src/discli/commands/dm.py +++ b/src/discli/commands/dm.py @@ -1,23 +1,7 @@ import click -from discli.client import run_discord -from discli.utils import output - - -def resolve_user(client, identifier: str): - """Resolve a user by ID or username.""" - try: - user_id = int(identifier) - user = client.get_user(user_id) - if user: - return user - except ValueError: - pass - for guild in client.guilds: - for member in guild.members: - if member.name.lower() == identifier.lower() or str(member).lower() == identifier.lower(): - return member - raise click.ClickException(f"User not found: {identifier}") +from discli.client import run_rest +from discli.utils import output, resolve_user @click.group("dm") @@ -36,7 +20,7 @@ def dm_send(ctx, user, text, files): def action(client): async def _action(client): - u = resolve_user(client, user) + u = await resolve_user(client, user) dm_channel = await u.create_dm() attachments = [discord.File(f) for f in files] kwargs = {"content": text} @@ -54,7 +38,7 @@ async def _action(client): output(ctx, data, plain_text=f"Sent DM to {u} (ID: {u.id}): {text[:50]}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @dm_group.command("list") @@ -66,7 +50,7 @@ def dm_list(ctx, user, limit): def action(client): async def _action(client): - u = resolve_user(client, user) + u = await resolve_user(client, user) dm_channel = await u.create_dm() messages = [] async for msg in dm_channel.history(limit=limit): @@ -84,4 +68,4 @@ async def _action(client): output(ctx, messages, plain_text="\n".join(plain_lines) if plain_lines else "No DMs found.") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/event.py b/src/discli/commands/event.py index 45a2011..614c4ad 100644 --- a/src/discli/commands/event.py +++ b/src/discli/commands/event.py @@ -1,7 +1,7 @@ import click import discord -from discli.client import run_discord +from discli.client import run_rest from discli.utils import output, resolve_guild @@ -17,8 +17,8 @@ def event_list(ctx, server): """List scheduled events in a server.""" def action(client): async def _action(client): - guild = resolve_guild(client, server) - events = guild.scheduled_events + guild = await resolve_guild(client, server) + events = await guild.fetch_scheduled_events() data = [] for e in events: data.append({ @@ -34,7 +34,7 @@ async def _action(client): plain_lines = [f"{ev['name']} ({ev['status']}) - {ev['start_time']}" for ev in data] output(ctx, data, plain_text="\n".join(plain_lines) if plain_lines else "No events.") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @event_group.command("create") @@ -52,7 +52,7 @@ def event_create(ctx, server, name, start_time, end_time, description, location, def action(client): async def _action(client): - guild = resolve_guild(client, server) + guild = await resolve_guild(client, server) try: start = datetime.fromisoformat(start_time) except ValueError: @@ -72,7 +72,7 @@ async def _action(client): raise click.ClickException("External events require --end-time") elif channel: from discli.utils import resolve_channel - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) kwargs["channel"] = ch if isinstance(ch, discord.StageChannel): kwargs["entity_type"] = discord.EntityType.stage_instance @@ -84,7 +84,7 @@ async def _action(client): data = {"id": str(event.id), "name": event.name} output(ctx, data, plain_text=f"Created event '{event.name}' (ID: {event.id})") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @event_group.command("delete") @@ -98,13 +98,11 @@ def event_delete(ctx, server, event_id): def action(client): async def _action(client): - guild = resolve_guild(client, server) - event = guild.get_scheduled_event(int(event_id)) - if not event: - raise click.ClickException(f"Event not found: {event_id}") + guild = await resolve_guild(client, server) + event = await guild.fetch_scheduled_event(int(event_id)) name = event.name await event.delete() audit_log("event delete", {"server": server, "event_id": event_id}) output(ctx, {"id": event_id, "deleted": True}, plain_text=f"Deleted event '{name}'") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/interact.py b/src/discli/commands/interact.py index df5af8d..61215e6 100644 --- a/src/discli/commands/interact.py +++ b/src/discli/commands/interact.py @@ -4,7 +4,7 @@ import click import discord -from discli.client import run_discord +from discli.client import run_rest from discli.utils import output, resolve_channel @@ -36,7 +36,7 @@ def action(client): async def _action(client): from discli.interact_engine import InteractEngine - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) # Parse field specs parsed_fields = [] @@ -92,7 +92,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) # --------------------------------------------------------------------------- @@ -141,7 +141,7 @@ async def _action(client): steps=steps, ) - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) engine = InteractEngine() workflow_key = await engine.workflow_start(ch, user_id, definition) @@ -154,7 +154,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) # --------------------------------------------------------------------------- @@ -198,7 +198,7 @@ async def _action(client): refresh_interval=raw.get("refresh_interval", 0), ) - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) engine = InteractEngine() dashboard_id = await engine.dashboard_create(ch, definition) @@ -211,7 +211,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @dashboard_group.command("delete") @@ -225,7 +225,7 @@ def action(client): async def _action(client): from discli.interact_engine import InteractEngine - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) engine = InteractEngine() await engine.dashboard_delete(dashboard_id, ch) @@ -238,4 +238,4 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/listen.py b/src/discli/commands/listen.py index c299032..f301767 100644 --- a/src/discli/commands/listen.py +++ b/src/discli/commands/listen.py @@ -13,7 +13,11 @@ @click.pass_context def listen_cmd(ctx, server, channel, events, ignore_bots): """Listen for real-time Discord events. Ctrl+C to stop.""" - from discli.client import resolve_token + from discli.client import ( + build_gateway_intents, + gateway_features_for_events, + resolve_token, + ) from discli.security import is_command_allowed profile = ctx.obj.get("profile") @@ -26,7 +30,7 @@ def listen_cmd(ctx, server, channel, events, ignore_bots): use_json = ctx.obj.get("use_json", False) event_filter = set(events.split(",")) if events else None - intents = discord.Intents.all() + intents = build_gateway_intents(gateway_features_for_events(event_filter)) client = discord.Client(intents=intents) def should_emit(guild, ch): @@ -228,7 +232,19 @@ async def on_voice_state_update(member, before, after): plain = f"{member} voice state updated in #{after.channel.name if after.channel else '?'}" emit(event_data, plain) + async def run_client(): + try: + await client.start(token) + finally: + if not client.is_closed(): + await client.close() + try: - asyncio.run(client.start(token)) + asyncio.run(run_client()) except KeyboardInterrupt: click.echo("\nStopped listening.", err=True) + except discord.PrivilegedIntentsRequired: + raise click.ClickException( + "Discord rejected a privileged intent required by the selected events. " + "Message events require Message Content; member events require Server Members." + ) diff --git a/src/discli/commands/member.py b/src/discli/commands/member.py index 503e339..058f9ab 100644 --- a/src/discli/commands/member.py +++ b/src/discli/commands/member.py @@ -1,23 +1,8 @@ import click -from discli.client import run_discord +from discli.client import run_rest from discli.security import audit_log, confirm_destructive, rate_limiter -from discli.utils import output, resolve_guild - - -def resolve_member(guild, identifier: str): - """Resolve a member by ID or name.""" - try: - member_id = int(identifier) - member = guild.get_member(member_id) - if member: - return member - except ValueError: - pass - for member in guild.members: - if member.name.lower() == identifier.lower() or str(member).lower() == identifier.lower(): - return member - raise click.ClickException(f"Member not found: {identifier}") +from discli.utils import output, resolve_guild, resolve_member @click.group("member") @@ -34,9 +19,10 @@ def member_list(ctx, server, limit): def action(client): async def _action(client): - guild = resolve_guild(client, server) + guild = await resolve_guild(client, server) members = [] - for m in guild.members[:limit]: + fetched_members = [m async for m in guild.fetch_members(limit=limit)] + for m in fetched_members: members.append({ "id": str(m.id), "name": str(m), @@ -54,7 +40,7 @@ async def _action(client): output(ctx, members, plain_text="\n".join(plain_lines) if plain_lines else "No members found.") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @member_group.command("info") @@ -66,8 +52,8 @@ def member_info(ctx, server, member): def action(client): async def _action(client): - guild = resolve_guild(client, server) - m = resolve_member(guild, member) + guild = await resolve_guild(client, server) + m = await resolve_member(guild, member) data = { "id": str(m.id), "name": str(m), @@ -80,7 +66,7 @@ async def _action(client): output(ctx, data, plain_text="\n".join(plain_lines)) return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @member_group.command("kick") @@ -96,18 +82,18 @@ def member_kick(ctx, server, member, reason, triggered_by): def action(client): async def _action(client): rate_limiter.wait() - guild = resolve_guild(client, server) + guild = await resolve_guild(client, server) if triggered_by: from discli.security import check_user_permission await check_user_permission(guild, int(triggered_by), "kick") - m = resolve_member(guild, member) + m = await resolve_member(guild, member) name = str(m) await m.kick(reason=reason) audit_log("member kick", {"server": server, "member": name, "reason": reason}, user=triggered_by or "") output(ctx, {"member": name, "kicked": True}, plain_text=f"Kicked {name}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @member_group.command("ban") @@ -123,18 +109,18 @@ def member_ban(ctx, server, member, reason, triggered_by): def action(client): async def _action(client): rate_limiter.wait() - guild = resolve_guild(client, server) + guild = await resolve_guild(client, server) if triggered_by: from discli.security import check_user_permission await check_user_permission(guild, int(triggered_by), "ban") - m = resolve_member(guild, member) + m = await resolve_member(guild, member) name = str(m) await m.ban(reason=reason) audit_log("member ban", {"server": server, "member": name, "reason": reason}, user=triggered_by or "") output(ctx, {"member": name, "banned": True}, plain_text=f"Banned {name}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @member_group.command("unban") @@ -149,7 +135,7 @@ def member_unban(ctx, server, member, triggered_by): def action(client): async def _action(client): rate_limiter.wait() - guild = resolve_guild(client, server) + guild = await resolve_guild(client, server) if triggered_by: from discli.security import check_user_permission await check_user_permission(guild, int(triggered_by), "ban") @@ -167,7 +153,7 @@ async def _action(client): output(ctx, {"member": str(target), "unbanned": True}, plain_text=f"Unbanned {target}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @member_group.command("timeout") @@ -185,11 +171,11 @@ def action(client): async def _action(client): from datetime import timedelta rate_limiter.wait() - guild = resolve_guild(client, server) + guild = await resolve_guild(client, server) if triggered_by: from discli.security import check_user_permission await check_user_permission(guild, int(triggered_by), "moderate_members") - m = resolve_member(guild, member) + m = await resolve_member(guild, member) if duration < 0: raise click.ClickException("Duration must be >= 0") if duration > 2419200: @@ -205,4 +191,4 @@ async def _action(client): output(ctx, {"member": name, "timeout_seconds": duration}, plain_text=f"Timed out {name} for {duration}s") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/message.py b/src/discli/commands/message.py index 30e43c6..dabcf11 100644 --- a/src/discli/commands/message.py +++ b/src/discli/commands/message.py @@ -1,7 +1,7 @@ import click import discord -from discli.client import run_discord +from discli.client import run_rest from discli.utils import output, resolve_channel @@ -28,7 +28,7 @@ def message_send(ctx, channel, text, embed_title, embed_desc, embed_color, embed def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) embed = None if any([embed_title, embed_desc, embed_color, embed_footer, embed_image, embed_thumbnail, embed_author, embed_field]): embed_kwargs = {} @@ -67,7 +67,7 @@ async def _action(client): output(ctx, data, plain_text=f"Sent message {msg.id} to #{ch.name}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @message_group.command("list") @@ -82,7 +82,7 @@ def message_list(ctx, channel, limit, before, after): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) kwargs = {"limit": limit} if before: kwargs["before"] = datetime.fromisoformat(before) @@ -130,7 +130,7 @@ async def _action(client): output(ctx, messages, plain_text="\n".join(plain_lines)) return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @message_group.command("history") @@ -145,7 +145,7 @@ def message_history(ctx, channel, days, hours, limit): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) kwargs = {} if days: kwargs["after"] = datetime.now(timezone.utc) - timedelta(days=days) @@ -202,7 +202,7 @@ async def _action(client): output(ctx, messages, plain_text="\n".join(plain_lines)) return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @message_group.command("edit") @@ -215,13 +215,13 @@ def message_edit(ctx, channel, message_id, new_text): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) msg = await ch.fetch_message(int(message_id)) await msg.edit(content=new_text) output(ctx, {"id": str(msg.id), "content": new_text}, plain_text=f"Edited message {msg.id}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @message_group.command("delete") @@ -235,14 +235,14 @@ def message_delete(ctx, channel, message_id): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) msg = await ch.fetch_message(int(message_id)) await msg.delete() audit_log("message delete", {"channel": channel, "message_id": message_id}) output(ctx, {"id": str(msg.id), "deleted": True}, plain_text=f"Deleted message {msg.id}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @message_group.command("get") @@ -254,7 +254,7 @@ def message_get(ctx, channel, message_id): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) msg = await ch.fetch_message(int(message_id)) data = { "id": str(msg.id), @@ -279,7 +279,7 @@ async def _action(client): output(ctx, data, plain_text="\n".join(plain_lines)) return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @message_group.command("reply") @@ -293,7 +293,7 @@ def message_reply(ctx, channel, message_id, text, files): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) original = await ch.fetch_message(int(message_id)) attachments = [discord.File(f) for f in files] kwargs = {"content": text} @@ -306,7 +306,7 @@ async def _action(client): output(ctx, data, plain_text=f"Replied to {message_id} in #{ch.name}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @message_group.command("search") @@ -324,7 +324,7 @@ def message_search(ctx, channel, query, limit, scan, author, before, after): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) kwargs = {"limit": scan} if before: kwargs["before"] = datetime.fromisoformat(before) @@ -396,7 +396,7 @@ async def _action(client): output(ctx, results, plain_text=f"Found {len(results)} match(es):\n" + "\n".join(plain_lines)) return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @message_group.command("bulk-delete") @@ -410,7 +410,7 @@ def message_bulk_delete(ctx, channel, message_ids): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) messages = [] for mid in message_ids: msg = await ch.fetch_message(int(mid)) @@ -420,4 +420,4 @@ async def _action(client): output(ctx, {"deleted": len(messages)}, plain_text=f"Deleted {len(messages)} messages") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/poll.py b/src/discli/commands/poll.py index 71d1917..dbf163f 100644 --- a/src/discli/commands/poll.py +++ b/src/discli/commands/poll.py @@ -3,7 +3,7 @@ import click import discord -from discli.client import run_discord +from discli.client import run_rest from discli.utils import output, resolve_channel @@ -37,7 +37,7 @@ def poll_create(ctx, channel, question, answers, duration, multiple, emoji): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) poll = discord.Poll( question=question, duration=datetime.timedelta(hours=duration), @@ -61,7 +61,7 @@ async def _action(client): output(ctx, data, plain_text=f"Poll created in #{ch.name} (message {msg.id}): {question}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @poll_group.command("results") @@ -72,7 +72,7 @@ def poll_results(ctx, channel, message_id): """View poll results.""" def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) msg = await ch.fetch_message(int(message_id)) if not msg.poll: raise click.ClickException("Message has no poll") @@ -94,7 +94,7 @@ async def _action(client): plain_lines.append(f" {a['text']}: {a['vote_count']} votes") output(ctx, data, plain_text="\n".join(plain_lines)) return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @poll_group.command("end") @@ -105,11 +105,11 @@ def poll_end(ctx, channel, message_id): """End a poll early.""" def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) msg = await ch.fetch_message(int(message_id)) if not msg.poll: raise click.ClickException("Message has no poll") await msg.end_poll() output(ctx, {"ended": True}, plain_text=f"Ended poll on message {message_id}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/reaction.py b/src/discli/commands/reaction.py index a4c565a..030d7eb 100644 --- a/src/discli/commands/reaction.py +++ b/src/discli/commands/reaction.py @@ -1,6 +1,6 @@ import click -from discli.client import run_discord +from discli.client import run_rest from discli.utils import output, resolve_channel @@ -19,13 +19,13 @@ def reaction_add(ctx, channel, message_id, emoji): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) msg = await ch.fetch_message(int(message_id)) await msg.add_reaction(emoji) output(ctx, {"message_id": message_id, "emoji": emoji}, plain_text=f"Reacted {emoji} to message {message_id}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @reaction_group.command("remove") @@ -38,13 +38,13 @@ def reaction_remove(ctx, channel, message_id, emoji): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) msg = await ch.fetch_message(int(message_id)) await msg.remove_reaction(emoji, client.user) output(ctx, {"message_id": message_id, "emoji": emoji}, plain_text=f"Removed {emoji} from message {message_id}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @reaction_group.command("list") @@ -56,7 +56,7 @@ def reaction_list(ctx, channel, message_id): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) msg = await ch.fetch_message(int(message_id)) reactions = [] for r in msg.reactions: @@ -65,7 +65,7 @@ async def _action(client): output(ctx, reactions, plain_text="\n".join(plain_lines) if plain_lines else "No reactions.") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @reaction_group.command("users") @@ -78,7 +78,7 @@ def reaction_users(ctx, channel, message_id, emoji, limit): """List users who reacted with a specific emoji.""" def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) msg = await ch.fetch_message(int(message_id)) target = None for r in msg.reactions: @@ -93,4 +93,4 @@ async def _action(client): plain_lines = [f"{u['name']} (ID: {u['id']}){' [bot]' if u['bot'] else ''}" for u in data] output(ctx, data, plain_text="\n".join(plain_lines)) return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/role.py b/src/discli/commands/role.py index b0debc0..db1b44f 100644 --- a/src/discli/commands/role.py +++ b/src/discli/commands/role.py @@ -1,36 +1,8 @@ import click import discord -from discli.client import run_discord -from discli.utils import output, resolve_guild - - -def resolve_role(guild, identifier: str): - try: - role_id = int(identifier) - role = guild.get_role(role_id) - if role: - return role - except ValueError: - pass - for role in guild.roles: - if role.name.lower() == identifier.lower(): - return role - raise click.ClickException(f"Role not found: {identifier}") - - -def resolve_member(guild, identifier: str): - try: - member_id = int(identifier) - member = guild.get_member(member_id) - if member: - return member - except ValueError: - pass - for member in guild.members: - if member.name.lower() == identifier.lower() or str(member).lower() == identifier.lower(): - return member - raise click.ClickException(f"Member not found: {identifier}") +from discli.client import run_rest +from discli.utils import output, resolve_guild, resolve_member, resolve_role @click.group("role") @@ -46,13 +18,36 @@ def role_list(ctx, server): def action(client): async def _action(client): - guild = resolve_guild(client, server) - roles = [{"id": str(r.id), "name": r.name, "color": str(r.color), "members": len(r.members)} for r in guild.roles if r.name != "@everyone"] - plain_lines = [f"{r['name']} (ID: {r['id']}, color: {r['color']}, members: {r['members']})" for r in roles] + guild = await resolve_guild(client, server) + fetched_roles = await guild.fetch_roles() + member_counts = None + try: + member_counts = {role.id: 0 for role in fetched_roles} + async for member in guild.fetch_members(limit=None): + for member_role in member.roles: + if member_role.id in member_counts: + member_counts[member_role.id] += 1 + except discord.Forbidden: + member_counts = None + roles = [ + { + "id": str(role.id), + "name": role.name, + "color": str(role.color), + "members": member_counts.get(role.id) if member_counts is not None else None, + } + for role in fetched_roles + if role.name != "@everyone" + ] + plain_lines = [ + f"{r['name']} (ID: {r['id']}, color: {r['color']}, " + f"members: {r['members'] if r['members'] is not None else 'unavailable'})" + for r in roles + ] output(ctx, roles, plain_text="\n".join(plain_lines) if plain_lines else "No roles.") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @role_group.command("create") @@ -66,7 +61,7 @@ def role_create(ctx, server, name, color, permissions): def action(client): async def _action(client): - guild = resolve_guild(client, server) + guild = await resolve_guild(client, server) kwargs = {"name": name} if color: try: @@ -80,7 +75,7 @@ async def _action(client): output(ctx, data, plain_text=f"Created role {role.name} (ID: {role.id})") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @role_group.command("delete") @@ -94,15 +89,15 @@ def role_delete(ctx, server, role): def action(client): async def _action(client): - guild = resolve_guild(client, server) - r = resolve_role(guild, role) + guild = await resolve_guild(client, server) + r = await resolve_role(guild, role) name = r.name await r.delete() audit_log("role delete", {"server": server, "role": name}) output(ctx, {"name": name, "deleted": True}, plain_text=f"Deleted role {name}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @role_group.command("assign") @@ -115,14 +110,14 @@ def role_assign(ctx, server, member, role): def action(client): async def _action(client): - guild = resolve_guild(client, server) - m = resolve_member(guild, member) - r = resolve_role(guild, role) + guild = await resolve_guild(client, server) + m = await resolve_member(guild, member) + r = await resolve_role(guild, role) await m.add_roles(r) output(ctx, {"member": str(m), "role": r.name}, plain_text=f"Assigned {r.name} to {m}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @role_group.command("remove") @@ -135,14 +130,14 @@ def role_remove(ctx, server, member, role): def action(client): async def _action(client): - guild = resolve_guild(client, server) - m = resolve_member(guild, member) - r = resolve_role(guild, role) + guild = await resolve_guild(client, server) + m = await resolve_member(guild, member) + r = await resolve_role(guild, role) await m.remove_roles(r) output(ctx, {"member": str(m), "role": r.name}, plain_text=f"Removed {r.name} from {m}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @role_group.command("edit") @@ -157,8 +152,8 @@ def role_edit(ctx, server, role, name, color, hoist, mentionable): """Edit a role's properties.""" def action(client): async def _action(client): - guild = resolve_guild(client, server) - r = resolve_role(guild, role) + guild = await resolve_guild(client, server) + r = await resolve_role(guild, role) kwargs = {} if name is not None: kwargs["name"] = name @@ -177,4 +172,4 @@ async def _action(client): data = {"id": str(r.id), "name": r.name, "updated": list(kwargs.keys())} output(ctx, data, plain_text=f"Updated role {r.name}: {', '.join(kwargs.keys())}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/serve.py b/src/discli/commands/serve.py index 15b8880..32c47ca 100644 --- a/src/discli/commands/serve.py +++ b/src/discli/commands/serve.py @@ -41,7 +41,11 @@ def serve_cmd(ctx, server, channel, events, include_self, slash_commands_file, status, activity, activity_text): """Start a persistent bot process with bidirectional JSONL communication.""" - from discli.client import resolve_token + from discli.client import ( + build_gateway_intents, + gateway_features_for_events, + resolve_token, + ) from discli.security import is_command_allowed profile = ctx.obj.get("profile") @@ -68,7 +72,9 @@ def serve_cmd(ctx, server, channel, events, include_self, slash_commands_file, print(f"[voice] opus load failed: {exc!r} — voice receive will not work", flush=True) - intents = discord.Intents.all() + gateway_features = gateway_features_for_events(event_filter) + gateway_features.add("voice") # Serve accepts voice actions dynamically. + intents = build_gateway_intents(gateway_features) client = discord.Client(intents=intents) tree = app_commands.CommandTree(client) if app_commands is not None else None @@ -127,8 +133,30 @@ def should_emit(guild, ch) -> bool: return False return True - def resolve_channel_by_id(channel_id: str): - return client.get_channel(int(channel_id)) + async def resolve_channel_by_id(channel_id: str): + channel = client.get_channel(int(channel_id)) + if channel is None: + channel = await client.fetch_channel(int(channel_id)) + return channel + + async def resolve_guild_by_id(guild_id: str): + guild = client.get_guild(int(guild_id)) + if guild is None: + guild = await client.fetch_guild(int(guild_id)) + return guild + + async def resolve_member_by_id(guild, member_id: str): + member = guild.get_member(int(member_id)) + if member is None: + member = await guild.fetch_member(int(member_id)) + return member + + async def resolve_role_by_id(guild, role_id: str): + role = guild.get_role(int(role_id)) + if role is not None: + return role + roles = await guild.fetch_roles() + return next((item for item in roles if item.id == int(role_id)), None) def _build_embed(embed_data: dict) -> discord.Embed: """Build a discord.Embed from a JSON-friendly dict.""" @@ -610,7 +638,7 @@ async def _set_presence(status_str: str, activity_type: str | None, activity_tex # ── Typing Management ────────────────────────────────────────── async def _typing_loop(channel_id: str): - ch = resolve_channel_by_id(channel_id) + ch = await resolve_channel_by_id(channel_id) if not ch: return try: @@ -668,7 +696,7 @@ async def _handle_stream_start(cmd: dict) -> dict: interaction_token = cmd.get("interaction_token") stream_id = str(uuid.uuid4())[:8] - ch = resolve_channel_by_id(channel_id) + ch = await resolve_channel_by_id(channel_id) msg = None if interaction_token and interaction_token in interactions: @@ -746,7 +774,7 @@ async def _handle_stream_end(cmd: dict) -> dict: await msg.edit(content=content[:DISCORD_MSG_LIMIT]) except discord.HTTPException: pass - ch = resolve_channel_by_id(stream["channel_id"]) + ch = await resolve_channel_by_id(stream["channel_id"]) if ch: remaining = content[DISCORD_MSG_LIMIT:] while remaining: @@ -832,7 +860,7 @@ async def _action_presence_set(cmd: dict) -> dict: # ── Action Handlers ──────────────────────────────────────────── async def _action_send(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd["channel_id"]) + ch = await resolve_channel_by_id(cmd["channel_id"]) if not ch: return {"error": f"Channel not found: {cmd['channel_id']}"} content = cmd.get("content", "") @@ -850,7 +878,7 @@ async def _action_send(cmd: dict) -> dict: return {"ok": True, "message_id": str(msg.id), "jump_url": msg.jump_url} async def _action_reply(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd["channel_id"]) + ch = await resolve_channel_by_id(cmd["channel_id"]) if not ch: return {"error": f"Channel not found: {cmd['channel_id']}"} original = await ch.fetch_message(int(cmd["message_id"])) @@ -869,7 +897,7 @@ async def _action_reply(cmd: dict) -> dict: return {"ok": True, "message_id": str(msg.id), "jump_url": msg.jump_url} async def _action_edit(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd["channel_id"]) + ch = await resolve_channel_by_id(cmd["channel_id"]) if not ch: return {"error": f"Channel not found: {cmd['channel_id']}"} msg = await ch.fetch_message(int(cmd["message_id"])) @@ -877,7 +905,7 @@ async def _action_edit(cmd: dict) -> dict: return {"ok": True} async def _action_delete(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd["channel_id"]) + ch = await resolve_channel_by_id(cmd["channel_id"]) if not ch: return {"error": f"Channel not found: {cmd['channel_id']}"} msg = await ch.fetch_message(int(cmd["message_id"])) @@ -885,7 +913,7 @@ async def _action_delete(cmd: dict) -> dict: return {"ok": True} async def _action_reaction_add(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd["channel_id"]) + ch = await resolve_channel_by_id(cmd["channel_id"]) if not ch: return {"error": f"Channel not found: {cmd['channel_id']}"} msg = await ch.fetch_message(int(cmd["message_id"])) @@ -893,7 +921,7 @@ async def _action_reaction_add(cmd: dict) -> dict: return {"ok": True} async def _action_reaction_remove(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd["channel_id"]) + ch = await resolve_channel_by_id(cmd["channel_id"]) if not ch: return {"error": f"Channel not found: {cmd['channel_id']}"} msg = await ch.fetch_message(int(cmd["message_id"])) @@ -992,7 +1020,7 @@ async def _action_thread_create(cmd: dict) -> dict: auto_archive = cmd.get("auto_archive_duration", 1440) content = cmd.get("content") - ch = resolve_channel_by_id(channel_id) + ch = await resolve_channel_by_id(channel_id) if not ch: return {"error": f"Channel not found: {channel_id}"} @@ -1021,7 +1049,7 @@ async def _action_thread_send(cmd: dict) -> dict: if not thread_id: return {"error": "Missing 'thread_id'"} content = cmd.get("content", "") - thread = client.get_channel(int(thread_id)) + thread = await resolve_channel_by_id(thread_id) if not thread: return {"error": f"Thread not found: {thread_id}"} files = [] @@ -1048,7 +1076,7 @@ async def _action_poll_send(cmd: dict) -> dict: if len(answers) < 2: return {"error": "Poll needs at least 2 answers"} - ch = resolve_channel_by_id(channel_id) + ch = await resolve_channel_by_id(channel_id) if not ch: return {"error": f"Channel not found: {channel_id}"} @@ -1072,7 +1100,7 @@ async def _action_poll_send(cmd: dict) -> dict: # ── Message Queries ──────────────────────────────────────────── async def _action_message_list(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} limit = cmd.get("limit", 20) @@ -1092,7 +1120,7 @@ async def _action_message_list(cmd: dict) -> dict: return {"ok": True, "messages": messages} async def _action_message_get(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} msg = await ch.fetch_message(int(cmd["message_id"])) @@ -1118,7 +1146,7 @@ async def _action_message_get(cmd: dict) -> dict: } async def _action_message_search(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} query = cmd.get("query", "").lower() @@ -1139,7 +1167,7 @@ async def _action_message_search(cmd: dict) -> dict: return {"ok": True, "messages": results} async def _action_message_pin(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} msg = await ch.fetch_message(int(cmd["message_id"])) @@ -1147,7 +1175,7 @@ async def _action_message_pin(cmd: dict) -> dict: return {"ok": True} async def _action_message_unpin(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} msg = await ch.fetch_message(int(cmd["message_id"])) @@ -1159,7 +1187,7 @@ async def _action_message_unpin(cmd: dict) -> dict: async def _action_channel_list(cmd: dict) -> dict: guild_id = cmd.get("guild_id") guilds = ( - [client.get_guild(int(guild_id))] if guild_id else client.guilds + [await resolve_guild_by_id(guild_id)] if guild_id else client.guilds ) channels = [] for guild in guilds: @@ -1184,7 +1212,7 @@ async def _action_channel_create(cmd: dict) -> dict: guild_id = cmd.get("guild_id") if not guild_id: return {"error": "Missing 'guild_id'"} - guild = client.get_guild(int(guild_id)) + guild = await resolve_guild_by_id(guild_id) if not guild: return {"error": f"Server not found: {guild_id}"} name = cmd.get("name") @@ -1200,7 +1228,7 @@ async def _action_channel_create(cmd: dict) -> dict: return {"ok": True, "channel_id": str(ch.id), "name": ch.name} async def _action_channel_info(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} return { @@ -1228,7 +1256,7 @@ async def _action_server_info(cmd: dict) -> dict: guild_id = cmd.get("guild_id") if not guild_id: return {"error": "Missing 'guild_id'"} - guild = client.get_guild(int(guild_id)) + guild = await resolve_guild_by_id(guild_id) if not guild: return {"error": f"Server not found: {guild_id}"} return { @@ -1269,12 +1297,12 @@ async def _action_member_list(cmd: dict) -> dict: guild_id = cmd.get("guild_id") if not guild_id: return {"error": "Missing 'guild_id'"} - guild = client.get_guild(int(guild_id)) + guild = await resolve_guild_by_id(guild_id) if not guild: return {"error": f"Server not found: {guild_id}"} limit = cmd.get("limit", 50) members = [] - for m in guild.members[:limit]: + async for m in guild.fetch_members(limit=limit): members.append({ "id": str(m.id), "name": str(m), @@ -1288,10 +1316,10 @@ async def _action_member_info(cmd: dict) -> dict: member_id = cmd.get("member_id") if not guild_id or not member_id: return {"error": "Missing 'guild_id' or 'member_id'"} - guild = client.get_guild(int(guild_id)) + guild = await resolve_guild_by_id(guild_id) if not guild: return {"error": f"Server not found: {guild_id}"} - member = guild.get_member(int(member_id)) + member = await resolve_member_by_id(guild, member_id) if not member: return {"error": f"Member not found: {member_id}"} return { @@ -1314,18 +1342,28 @@ async def _action_role_list(cmd: dict) -> dict: guild_id = cmd.get("guild_id") if not guild_id: return {"error": "Missing 'guild_id'"} - guild = client.get_guild(int(guild_id)) + guild = await resolve_guild_by_id(guild_id) if not guild: return {"error": f"Server not found: {guild_id}"} roles = [] - for r in guild.roles: + fetched_roles = await guild.fetch_roles() + member_counts = None + try: + member_counts = {role.id: 0 for role in fetched_roles} + async for member in guild.fetch_members(limit=None): + for member_role in member.roles: + if member_role.id in member_counts: + member_counts[member_role.id] += 1 + except discord.Forbidden: + pass + for r in fetched_roles: if r.name == "@everyone": continue roles.append({ "id": str(r.id), "name": r.name, "color": str(r.color), - "members": len(r.members), + "members": member_counts.get(r.id) if member_counts is not None else None, }) return {"ok": True, "roles": roles} @@ -1335,13 +1373,13 @@ async def _action_role_assign(cmd: dict) -> dict: role_id = cmd.get("role_id") if not all([guild_id, member_id, role_id]): return {"error": "Missing 'guild_id', 'member_id', or 'role_id'"} - guild = client.get_guild(int(guild_id)) + guild = await resolve_guild_by_id(guild_id) if not guild: return {"error": f"Server not found: {guild_id}"} - member = guild.get_member(int(member_id)) + member = await resolve_member_by_id(guild, member_id) if not member: return {"error": f"Member not found: {member_id}"} - role = guild.get_role(int(role_id)) + role = await resolve_role_by_id(guild, role_id) if not role: return {"error": f"Role not found: {role_id}"} await member.add_roles(role) @@ -1353,13 +1391,13 @@ async def _action_role_remove(cmd: dict) -> dict: role_id = cmd.get("role_id") if not all([guild_id, member_id, role_id]): return {"error": "Missing 'guild_id', 'member_id', or 'role_id'"} - guild = client.get_guild(int(guild_id)) + guild = await resolve_guild_by_id(guild_id) if not guild: return {"error": f"Server not found: {guild_id}"} - member = guild.get_member(int(member_id)) + member = await resolve_member_by_id(guild, member_id) if not member: return {"error": f"Member not found: {member_id}"} - role = guild.get_role(int(role_id)) + role = await resolve_role_by_id(guild, role_id) if not role: return {"error": f"Role not found: {role_id}"} await member.remove_roles(role) @@ -1368,7 +1406,7 @@ async def _action_role_remove(cmd: dict) -> dict: # ── Thread Queries ───────────────────────────────────────────── async def _action_thread_list(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} threads = [] @@ -1385,7 +1423,7 @@ async def _action_thread_list(cmd: dict) -> dict: # ── New Action Handlers ─────────────────────────────────────── async def _action_message_bulk_delete(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} message_ids = cmd.get("message_ids", []) @@ -1424,7 +1462,7 @@ async def _action_modal_send(cmd: dict) -> dict: return {"ok": True} async def _action_channel_edit(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} kwargs = {} @@ -1442,7 +1480,7 @@ async def _action_channel_edit(cmd: dict) -> dict: return {"ok": True, "channel_id": str(ch.id), "updated": list(kwargs.keys())} async def _action_channel_set_permissions(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} target_type = cmd.get("target_type", "role") @@ -1451,9 +1489,9 @@ async def _action_channel_set_permissions(cmd: dict) -> dict: return {"error": "Missing 'target_id'"} guild = ch.guild if target_type == "role": - obj = guild.get_role(int(target_id)) + obj = await resolve_role_by_id(guild, target_id) else: - obj = guild.get_member(int(target_id)) + obj = await resolve_member_by_id(guild, target_id) if not obj: return {"error": f"Target not found: {target_id}"} overwrite = ch.overwrites_for(obj) @@ -1468,7 +1506,7 @@ async def _action_channel_set_permissions(cmd: dict) -> dict: return {"ok": True} async def _action_forum_post(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} if not isinstance(ch, discord.ForumChannel): @@ -1485,7 +1523,7 @@ async def _action_forum_post(cmd: dict) -> dict: async def _action_thread_archive(cmd: dict) -> dict: thread_id = cmd.get("thread_id") - thread = client.get_channel(int(thread_id)) + thread = await resolve_channel_by_id(thread_id) if not thread: return {"error": f"Thread not found: {thread_id}"} await thread.edit(archived=cmd.get("archived", True)) @@ -1494,7 +1532,7 @@ async def _action_thread_archive(cmd: dict) -> dict: async def _action_thread_rename(cmd: dict) -> dict: thread_id = cmd.get("thread_id") name = cmd.get("name") - thread = client.get_channel(int(thread_id)) + thread = await resolve_channel_by_id(thread_id) if not thread: return {"error": f"Thread not found: {thread_id}"} await thread.edit(name=name) @@ -1503,10 +1541,10 @@ async def _action_thread_rename(cmd: dict) -> dict: async def _action_thread_add_member(cmd: dict) -> dict: thread_id = cmd.get("thread_id") member_id = cmd.get("member_id") - thread = client.get_channel(int(thread_id)) + thread = await resolve_channel_by_id(thread_id) if not thread: return {"error": f"Thread not found: {thread_id}"} - member = thread.guild.get_member(int(member_id)) + member = await resolve_member_by_id(thread.guild, member_id) if not member: return {"error": f"Member not found: {member_id}"} await thread.add_user(member) @@ -1515,10 +1553,10 @@ async def _action_thread_add_member(cmd: dict) -> dict: async def _action_thread_remove_member(cmd: dict) -> dict: thread_id = cmd.get("thread_id") member_id = cmd.get("member_id") - thread = client.get_channel(int(thread_id)) + thread = await resolve_channel_by_id(thread_id) if not thread: return {"error": f"Thread not found: {thread_id}"} - member = thread.guild.get_member(int(member_id)) + member = await resolve_member_by_id(thread.guild, member_id) if not member: return {"error": f"Member not found: {member_id}"} await thread.remove_user(member) @@ -1532,10 +1570,10 @@ async def _action_member_timeout(cmd: dict) -> dict: reason = cmd.get("reason") if not guild_id or not member_id: return {"error": "Missing 'guild_id' or 'member_id'"} - guild = client.get_guild(int(guild_id)) + guild = await resolve_guild_by_id(guild_id) if not guild: return {"error": f"Server not found: {guild_id}"} - member = guild.get_member(int(member_id)) + member = await resolve_member_by_id(guild, member_id) if not member: return {"error": f"Member not found: {member_id}"} if duration < 0 or duration > 2419200: @@ -1551,10 +1589,10 @@ async def _action_role_edit(cmd: dict) -> dict: role_id = cmd.get("role_id") if not guild_id or not role_id: return {"error": "Missing 'guild_id' or 'role_id'"} - guild = client.get_guild(int(guild_id)) + guild = await resolve_guild_by_id(guild_id) if not guild: return {"error": f"Server not found: {guild_id}"} - role = guild.get_role(int(role_id)) + role = await resolve_role_by_id(guild, role_id) if not role: return {"error": f"Role not found: {role_id}"} kwargs = {} @@ -1573,7 +1611,7 @@ async def _action_role_edit(cmd: dict) -> dict: return {"ok": True, "role_id": str(role.id), "updated": list(kwargs.keys())} async def _action_reaction_users(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} msg = await ch.fetch_message(int(cmd["message_id"])) @@ -1592,7 +1630,7 @@ async def _action_reaction_users(cmd: dict) -> dict: ]} async def _action_poll_results(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} msg = await ch.fetch_message(int(cmd["message_id"])) @@ -1615,7 +1653,7 @@ async def _action_poll_results(cmd: dict) -> dict: } async def _action_poll_end(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} msg = await ch.fetch_message(int(cmd["message_id"])) @@ -1625,7 +1663,7 @@ async def _action_poll_end(cmd: dict) -> dict: return {"ok": True} async def _action_webhook_list(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} webhooks = await ch.webhooks() @@ -1634,7 +1672,7 @@ async def _action_webhook_list(cmd: dict) -> dict: ]} async def _action_webhook_create(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} name = cmd.get("name", "discli webhook") @@ -1642,7 +1680,7 @@ async def _action_webhook_create(cmd: dict) -> dict: return {"ok": True, "webhook_id": str(webhook.id), "name": webhook.name, "url": webhook.url} async def _action_webhook_delete(cmd: dict) -> dict: - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel not found: {cmd.get('channel_id')}"} webhooks = await ch.webhooks() @@ -1660,10 +1698,10 @@ async def _action_event_list(cmd: dict) -> dict: guild_id = cmd.get("guild_id") if not guild_id: return {"error": "Missing 'guild_id'"} - guild = client.get_guild(int(guild_id)) + guild = await resolve_guild_by_id(guild_id) if not guild: return {"error": f"Server not found: {guild_id}"} - events = guild.scheduled_events + events = await guild.fetch_scheduled_events() return {"ok": True, "events": [ { "id": str(e.id), @@ -1682,7 +1720,7 @@ async def _action_event_create(cmd: dict) -> dict: guild_id = cmd.get("guild_id") if not guild_id: return {"error": "Missing 'guild_id'"} - guild = client.get_guild(int(guild_id)) + guild = await resolve_guild_by_id(guild_id) if not guild: return {"error": f"Server not found: {guild_id}"} name = cmd.get("name") @@ -1704,7 +1742,7 @@ async def _action_event_create(cmd: dict) -> dict: kwargs["location"] = cmd["location"] kwargs["entity_type"] = discord.EntityType.external elif "channel_id" in cmd: - ch = resolve_channel_by_id(cmd["channel_id"]) + ch = await resolve_channel_by_id(cmd["channel_id"]) kwargs["channel"] = ch kwargs["entity_type"] = discord.EntityType.voice else: @@ -1847,7 +1885,7 @@ async def _action_voice_where(cmd: dict) -> dict: except (TypeError, ValueError): return {"error": f"Invalid user_id: {user_id}"} - guilds = [client.get_guild(int(guild_id))] if guild_id else list(client.guilds) + guilds = [await resolve_guild_by_id(guild_id)] if guild_id else list(client.guilds) matches = [] for guild in guilds: if guild is None: @@ -1930,7 +1968,7 @@ async def _action_workflow_start(cmd: dict) -> dict: user_id = cmd.get("user_id") raw = cmd.get("workflow", {}) - ch = resolve_channel_by_id(channel_id) + ch = await resolve_channel_by_id(channel_id) if not ch: return {"error": f"Channel {channel_id} not found"} @@ -1963,7 +2001,7 @@ async def _action_dashboard_create(cmd: dict) -> dict: from discli.interact_engine import DashboardDefinition, DashboardPage channel_id = cmd.get("channel_id") - ch = resolve_channel_by_id(channel_id) + ch = await resolve_channel_by_id(channel_id) if not ch: return {"error": f"Channel {channel_id} not found"} @@ -1982,7 +2020,7 @@ async def _action_dashboard_create(cmd: dict) -> dict: async def _action_dashboard_update(cmd: dict) -> dict: engine = _get_interact_engine() - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel {cmd.get('channel_id')} not found"} await engine.dashboard_update(cmd.get("dashboard_id"), cmd.get("updates", {}), ch) @@ -1990,7 +2028,7 @@ async def _action_dashboard_update(cmd: dict) -> dict: async def _action_dashboard_delete(cmd: dict) -> dict: engine = _get_interact_engine() - ch = resolve_channel_by_id(cmd.get("channel_id")) + ch = await resolve_channel_by_id(cmd.get("channel_id")) if not ch: return {"error": f"Channel {cmd.get('channel_id')} not found"} await engine.dashboard_delete(cmd.get("dashboard_id"), ch) @@ -2108,9 +2146,24 @@ async def _dispatch(cmd: dict) -> dict: # ── Run (reconnect=True is discord.py default) ───────────────── + async def run_client(): + try: + await client.start(token, reconnect=True) + finally: + if not client.is_closed(): + await client.close() + try: - asyncio.run(client.start(token, reconnect=True)) + asyncio.run(run_client()) except KeyboardInterrupt: emit({"event": "shutdown"}) + except discord.PrivilegedIntentsRequired: + emit({ + "event": "error", + "message": ( + "Fatal: Discord rejected a privileged intent required by the selected events. " + "Message events require Message Content; member events require Server Members." + ), + }) except Exception as e: emit({"event": "error", "message": f"Fatal: {e}"}) diff --git a/src/discli/commands/server.py b/src/discli/commands/server.py index 032a09f..40a52f0 100644 --- a/src/discli/commands/server.py +++ b/src/discli/commands/server.py @@ -1,7 +1,7 @@ import click -from discli.client import run_discord -from discli.utils import output, resolve_guild +from discli.client import run_rest +from discli.utils import fetch_guilds, output, resolve_guild @click.group("server") @@ -17,17 +17,20 @@ def server_list(ctx): def action(client): async def _action(client): servers = [] - for g in client.guilds: + for g in await fetch_guilds(client): + member_count = g.member_count + if member_count is None: + member_count = g.approximate_member_count servers.append({ "id": str(g.id), "name": g.name, - "member_count": g.member_count, + "member_count": member_count, }) plain_lines = [f"{s['name']} (ID: {s['id']}, members: {s['member_count']})" for s in servers] output(ctx, servers, plain_text="\n".join(plain_lines) if plain_lines else "Bot is not in any servers.") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @server_group.command("info") @@ -38,18 +41,29 @@ def server_info(ctx, server): def action(client): async def _action(client): - guild = resolve_guild(client, server) + guild = await resolve_guild(client, server) + channels = await guild.fetch_channels() + roles = await guild.fetch_roles() + member_count = guild.member_count + if member_count is None: + member_count = guild.approximate_member_count + owner = str(guild.owner_id) + if guild.owner_id: + try: + owner = str(await guild.fetch_member(guild.owner_id)) + except Exception: + pass data = { "id": str(guild.id), "name": guild.name, - "owner": str(guild.owner), - "member_count": guild.member_count, - "channel_count": len(guild.channels), - "role_count": len(guild.roles), + "owner": owner, + "member_count": member_count, + "channel_count": len(channels), + "role_count": len(roles), "created_at": guild.created_at.isoformat(), } plain_lines = [f"{k}: {v}" for k, v in data.items()] output(ctx, data, plain_text="\n".join(plain_lines)) return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/thread.py b/src/discli/commands/thread.py index cc6cf5e..ba35c85 100644 --- a/src/discli/commands/thread.py +++ b/src/discli/commands/thread.py @@ -1,25 +1,8 @@ import click import discord -from discli.client import run_discord -from discli.utils import output, resolve_channel, resolve_guild - - -def resolve_thread(client, identifier: str): - """Resolve a thread by ID or name.""" - try: - thread_id = int(identifier) - for guild in client.guilds: - for thread in guild.threads: - if thread.id == thread_id: - return thread - except ValueError: - pass - for guild in client.guilds: - for thread in guild.threads: - if thread.name.lower() == identifier.lower(): - return thread - raise click.ClickException(f"Thread not found: {identifier}") +from discli.client import run_rest +from discli.utils import output, resolve_channel, resolve_member, resolve_thread @click.group("thread") @@ -37,7 +20,7 @@ def thread_create(ctx, channel, message_id, name): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) msg = await ch.fetch_message(int(message_id)) thread = await msg.create_thread(name=name) data = { @@ -50,7 +33,7 @@ async def _action(client): output(ctx, data, plain_text=f"Created thread '{thread.name}' (ID: {thread.id}) from message {message_id}") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @thread_group.command("list") @@ -61,9 +44,12 @@ def thread_list(ctx, channel): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) threads = [] - for thread in ch.threads: + active_threads = await ch.guild.active_threads() + for thread in active_threads: + if thread.parent_id != ch.id: + continue threads.append({ "id": str(thread.id), "name": thread.name, @@ -78,7 +64,7 @@ async def _action(client): output(ctx, threads, plain_text="\n".join(plain_lines) if plain_lines else "No active threads.") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @thread_group.command("send") @@ -91,7 +77,7 @@ def thread_send(ctx, thread, text, files): def action(client): async def _action(client): - t = resolve_thread(client, thread) + t = await resolve_thread(client, thread) attachments = [discord.File(f) for f in files] kwargs = {"content": text} if attachments: @@ -108,7 +94,7 @@ async def _action(client): output(ctx, data, plain_text=f"Sent message {msg.id} to thread '{t.name}'") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @thread_group.command("archive") @@ -118,11 +104,11 @@ def thread_archive(ctx, thread): """Archive a thread.""" def action(client): async def _action(client): - t = resolve_thread(client, thread) + t = await resolve_thread(client, thread) await t.edit(archived=True) output(ctx, {"id": str(t.id), "archived": True}, plain_text=f"Archived thread '{t.name}'") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @thread_group.command("unarchive") @@ -132,11 +118,11 @@ def thread_unarchive(ctx, thread): """Unarchive a thread.""" def action(client): async def _action(client): - t = resolve_thread(client, thread) + t = await resolve_thread(client, thread) await t.edit(archived=False) output(ctx, {"id": str(t.id), "archived": False}, plain_text=f"Unarchived thread '{t.name}'") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @thread_group.command("rename") @@ -147,13 +133,13 @@ def thread_rename(ctx, thread, new_name): """Rename a thread.""" def action(client): async def _action(client): - t = resolve_thread(client, thread) + t = await resolve_thread(client, thread) old = t.name await t.edit(name=new_name) output(ctx, {"id": str(t.id), "old_name": old, "new_name": new_name}, plain_text=f"Renamed thread '{old}' to '{new_name}'") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @thread_group.command("add-member") @@ -164,19 +150,17 @@ def thread_add_member(ctx, thread, member_id): """Add a member to a thread.""" def action(client): async def _action(client): - t = resolve_thread(client, thread) + t = await resolve_thread(client, thread) try: mid = int(member_id) except ValueError: raise click.ClickException(f"Invalid member ID: {member_id}") - member = t.guild.get_member(mid) - if not member: - raise click.ClickException(f"Member not found: {member_id}") + member = await resolve_member(t.guild, str(mid)) await t.add_user(member) output(ctx, {"thread_id": str(t.id), "member": str(member)}, plain_text=f"Added {member} to thread '{t.name}'") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @thread_group.command("remove-member") @@ -187,16 +171,14 @@ def thread_remove_member(ctx, thread, member_id): """Remove a member from a thread.""" def action(client): async def _action(client): - t = resolve_thread(client, thread) + t = await resolve_thread(client, thread) try: mid = int(member_id) except ValueError: raise click.ClickException(f"Invalid member ID: {member_id}") - member = t.guild.get_member(mid) - if not member: - raise click.ClickException(f"Member not found: {member_id}") + member = await resolve_member(t.guild, str(mid)) await t.remove_user(member) output(ctx, {"thread_id": str(t.id), "member": str(member)}, plain_text=f"Removed {member} from thread '{t.name}'") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/typing_cmd.py b/src/discli/commands/typing_cmd.py index 266babe..935b4d8 100644 --- a/src/discli/commands/typing_cmd.py +++ b/src/discli/commands/typing_cmd.py @@ -2,7 +2,7 @@ import click -from discli.client import run_discord +from discli.client import run_rest from discli.utils import resolve_channel @@ -15,10 +15,10 @@ def typing_cmd(ctx, channel, duration): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) async with ch.typing(): await asyncio.sleep(duration) click.echo(f"Typed in #{ch.name} for {duration}s") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/commands/voice.py b/src/discli/commands/voice.py index f7c254e..b8c54a0 100644 --- a/src/discli/commands/voice.py +++ b/src/discli/commands/voice.py @@ -3,8 +3,24 @@ import click import discord -from discli.client import run_discord -from discli.utils import output, resolve_guild +from discli.client import run_gateway +from discli.utils import output + + +def _resolve_cached_guild(client, identifier: str): + """Resolve a guild from Gateway state for voice operations.""" + try: + guild = client.get_guild(int(identifier)) + if guild is not None: + return guild + except ValueError: + pass + matches = [guild for guild in client.guilds if guild.name.casefold() == identifier.casefold()] + if not matches: + raise click.ClickException(f"Server not found: {identifier}") + if len(matches) > 1: + raise click.ClickException(f"Multiple servers match '{identifier}'. Use a server ID.") + return matches[0] def _require_voice_extras_or_exit() -> None: @@ -29,7 +45,7 @@ def _require_voice_extras_or_exit() -> None: def _resolve_voice_channel(client, channel_identifier: str, server: str | None): """Resolve a voice channel by name or ID, optionally scoped to a server.""" if server: - guilds = [resolve_guild(client, server)] + guilds = [_resolve_cached_guild(client, server)] else: guilds = client.guilds @@ -54,7 +70,7 @@ def _resolve_voice_channel(client, channel_identifier: str, server: str | None): def _find_active_voice_guild(client, server: str | None): """Return the guild that has an active voice_client, or raise ClickException.""" if server: - guild = resolve_guild(client, server) + guild = _resolve_cached_guild(client, server) if guild.voice_client is not None: return guild raise click.ClickException(f"No active voice connection in server: {server}") @@ -101,7 +117,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) @voice_group.command("leave") @@ -129,7 +145,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) @voice_group.command("speak") @@ -165,7 +181,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) @voice_group.command("play") @@ -199,7 +215,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) @voice_group.command("stop") @@ -228,7 +244,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) @voice_group.command("pause") @@ -255,7 +271,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) @voice_group.command("resume") @@ -282,7 +298,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) @voice_group.command("listen") @@ -359,7 +375,7 @@ def on_event(event: dict): return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) @voice_group.command("capture") @@ -520,7 +536,7 @@ def cleanup(self) -> None: return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) @voice_group.command("where") @@ -532,7 +548,7 @@ def voice_where(ctx, user, server): def action(client): async def _action(client): - guilds = [resolve_guild(client, server)] if server else client.guilds + guilds = [_resolve_cached_guild(client, server)] if server else client.guilds try: user_id = int(user) @@ -577,7 +593,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) @voice_group.command("members") @@ -622,7 +638,7 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) @voice_group.command("status") @@ -660,4 +676,4 @@ async def _action(client): return _action(client) - run_discord(ctx, action) + run_gateway(ctx, action, features={"voice"}) diff --git a/src/discli/commands/webhook.py b/src/discli/commands/webhook.py index 0f1fe24..50cddd4 100644 --- a/src/discli/commands/webhook.py +++ b/src/discli/commands/webhook.py @@ -1,7 +1,7 @@ import click import discord -from discli.client import run_discord +from discli.client import run_rest from discli.utils import output, resolve_channel @@ -17,13 +17,13 @@ def webhook_list(ctx, channel): """List webhooks in a channel.""" def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) webhooks = await ch.webhooks() data = [{"id": str(w.id), "name": w.name, "url": w.url} for w in webhooks] plain_lines = [f"{w['name']} (ID: {w['id']})" for w in data] output(ctx, data, plain_text="\n".join(plain_lines) if plain_lines else "No webhooks.") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @webhook_group.command("create") @@ -34,12 +34,12 @@ def webhook_create(ctx, channel, name): """Create a webhook in a channel.""" def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) webhook = await ch.create_webhook(name=name) data = {"id": str(webhook.id), "name": webhook.name, "url": webhook.url} output(ctx, data, plain_text=f"Created webhook '{webhook.name}' (URL: {webhook.url})") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) @webhook_group.command("delete") @@ -53,7 +53,7 @@ def webhook_delete(ctx, channel, webhook_id): def action(client): async def _action(client): - ch = resolve_channel(client, channel) + ch = await resolve_channel(client, channel) webhooks = await ch.webhooks() target = None for w in webhooks: @@ -67,4 +67,4 @@ async def _action(client): audit_log("webhook delete", {"channel": channel, "webhook_id": webhook_id}) output(ctx, {"id": webhook_id, "deleted": True}, plain_text=f"Deleted webhook '{name}'") return _action(client) - run_discord(ctx, action) + run_rest(ctx, action) diff --git a/src/discli/utils.py b/src/discli/utils.py index 6891a93..1e7c461 100644 --- a/src/discli/utils.py +++ b/src/discli/utils.py @@ -2,6 +2,7 @@ from typing import Any import click +import discord def format_output(data: Any, use_json: bool = False) -> str: @@ -19,35 +20,221 @@ def output(ctx: click.Context, data: Any, plain_text: str | None = None) -> None click.echo(plain_text if plain_text is not None else format_output(data)) -def resolve_channel(client, identifier: str): - """Resolve a channel by ID or #name.""" - if identifier.startswith("#"): - name = identifier[1:] - for guild in client.guilds: - for ch in guild.text_channels: - if ch.name == name: - return ch - raise click.ClickException(f"Channel not found: #{name}") +async def fetch_guilds(client) -> list[discord.Guild]: + """Return cached guilds when available, otherwise fetch them over HTTP.""" + cached = list(getattr(client, "guilds", ())) + if cached: + return cached + return [guild async for guild in client.fetch_guilds(limit=None)] + + +def _unique_match(matches: list[Any], kind: str, identifier: str): + if not matches: + raise click.ClickException(f"{kind} not found: {identifier}") + if len(matches) > 1: + ids = ", ".join(str(item.id) for item in matches) + raise click.ClickException( + f"Multiple {kind.lower()}s match '{identifier}' (IDs: {ids}). Use an ID." + ) + return matches[0] + + +async def resolve_guild(client, identifier: str) -> discord.Guild: + """Resolve a guild by ID or name, preferring Gateway cache state.""" + try: + guild_id = int(identifier) + except ValueError: + pass else: - try: - channel = client.get_channel(int(identifier)) - if channel is None: - raise click.ClickException(f"Channel not found: {identifier}") + get_guild = getattr(client, "get_guild", None) + cached = get_guild(guild_id) if get_guild else None + if cached is not None: + return cached + return await client.fetch_guild(guild_id) + + cached_matches = [ + guild + for guild in getattr(client, "guilds", ()) + if guild.name.casefold() == identifier.casefold() + ] + if cached_matches: + return _unique_match(cached_matches, "Server", identifier) + + matches = [ + guild + async for guild in client.fetch_guilds(limit=None) + if guild.name.casefold() == identifier.casefold() + ] + return _unique_match(matches, "Server", identifier) + + +async def resolve_channel(client, identifier: str): + """Resolve a guild channel by ID/name or a thread by ID, cache first.""" + normalized = identifier.removeprefix("#") + try: + channel_id = int(normalized) + except ValueError: + pass + else: + get_channel = getattr(client, "get_channel", None) + cached = get_channel(channel_id) if get_channel else None + if cached is not None: + return cached + return await client.fetch_channel(channel_id) + + cached_matches = [] + for guild in getattr(client, "guilds", ()): + cached_matches.extend( + channel + for channel in getattr(guild, "channels", ()) + if channel.name.casefold() == normalized.casefold() + ) + if cached_matches: + return _unique_match(cached_matches, "Channel", identifier) + + matches = [] + for guild in await fetch_guilds(client): + channels = await guild.fetch_channels() + matches.extend(ch for ch in channels if ch.name.casefold() == normalized.casefold()) + return _unique_match(matches, "Channel", identifier) + + +async def resolve_thread(client, identifier: str) -> discord.Thread: + """Resolve an active thread by ID or name, preferring cached threads.""" + try: + thread_id = int(identifier) + except ValueError: + pass + else: + get_channel = getattr(client, "get_channel", None) + cached = get_channel(thread_id) if get_channel else None + if cached is not None: + if isinstance(cached, discord.Thread): + return cached + raise click.ClickException(f"Channel is not a thread: {identifier}") + channel = await client.fetch_channel(thread_id) + if isinstance(channel, discord.Thread): return channel - except ValueError: - raise click.ClickException(f"Invalid channel identifier: {identifier}") + raise click.ClickException(f"Channel is not a thread: {identifier}") + cached_matches = [] + for guild in getattr(client, "guilds", ()): + cached_matches.extend( + thread + for thread in getattr(guild, "threads", ()) + if thread.name.casefold() == identifier.casefold() + ) + if cached_matches: + return _unique_match(cached_matches, "Thread", identifier) -def resolve_guild(client, identifier: str): - """Resolve a guild/server by ID or name.""" + matches = [] + for guild in await fetch_guilds(client): + threads = await guild.active_threads() + matches.extend(thread for thread in threads if thread.name.casefold() == identifier.casefold()) + return _unique_match(matches, "Thread", identifier) + + +async def resolve_member(guild: discord.Guild, identifier: str) -> discord.Member: + """Resolve a guild member, preferring cached state and direct ID lookup.""" try: - guild_id = int(identifier) - guild = client.get_guild(guild_id) - if guild: - return guild + member_id = int(identifier) except ValueError: pass - for guild in client.guilds: - if guild.name.lower() == identifier.lower(): - return guild - raise click.ClickException(f"Server not found: {identifier}") + else: + get_member = getattr(guild, "get_member", None) + cached = get_member(member_id) if get_member else None + if cached is not None: + return cached + return await guild.fetch_member(member_id) + + normalized = identifier.removeprefix("@").casefold() + cached_matches = [ + member + for member in getattr(guild, "members", ()) + if member.name.casefold() == normalized or str(member).casefold() == normalized + ] + if cached_matches: + return _unique_match(cached_matches, "Member", identifier) + + try: + members = [member async for member in guild.fetch_members(limit=None)] + except discord.Forbidden: + raise click.ClickException( + "Member name lookup requires Discord's Server Members privileged intent. " + "Use the numeric member ID or enable that intent for this application." + ) + matches = [ + member + for member in members + if member.name.casefold() == normalized or str(member).casefold() == normalized + ] + return _unique_match(matches, "Member", identifier) + + +async def resolve_user(client, identifier: str): + """Resolve a user by ID or shared guild membership, preferring cache state.""" + try: + user_id = int(identifier) + except ValueError: + pass + else: + get_user = getattr(client, "get_user", None) + cached = get_user(user_id) if get_user else None + if cached is not None: + return cached + return await client.fetch_user(user_id) + + normalized = identifier.removeprefix("@").casefold() + cached_matches: dict[int, discord.Member] = {} + for guild in getattr(client, "guilds", ()): + for member in getattr(guild, "members", ()): + if member.name.casefold() == normalized or str(member).casefold() == normalized: + cached_matches[member.id] = member + if cached_matches: + return _unique_match(list(cached_matches.values()), "User", identifier) + + matches: dict[int, discord.Member] = {} + members_forbidden = False + for guild in await fetch_guilds(client): + try: + async for member in guild.fetch_members(limit=None): + if member.name.casefold() == normalized or str(member).casefold() == normalized: + matches[member.id] = member + except discord.Forbidden: + members_forbidden = True + + if not matches and members_forbidden: + raise click.ClickException( + "User name lookup requires Discord's Server Members privileged intent. " + "Use the numeric user ID or enable that intent for this application." + ) + return _unique_match(list(matches.values()), "User", identifier) + + +async def resolve_role(guild: discord.Guild, identifier: str) -> discord.Role: + """Resolve a guild role by ID or name, preferring cached roles.""" + role_id = None + try: + role_id = int(identifier) + except ValueError: + cached_matches = [ + role + for role in getattr(guild, "roles", ()) + if role.name.casefold() == identifier.casefold() + ] + else: + get_role = getattr(guild, "get_role", None) + cached = get_role(role_id) if get_role else None + if cached is not None: + return cached + cached_matches = [] + + if cached_matches: + return _unique_match(cached_matches, "Role", identifier) + + roles = await guild.fetch_roles() + if role_id is not None: + matches = [role for role in roles if role.id == role_id] + else: + matches = [role for role in roles if role.name.casefold() == identifier.casefold()] + return _unique_match(matches, "Role", identifier) diff --git a/tests/test_client.py b/tests/test_client.py index 18b411b..718f7e1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,13 @@ import click import pytest -from discli.client import resolve_token +from discli.client import ( + build_gateway_intents, + gateway_features_for_events, + resolve_token, + run_gateway_action, + run_rest_action, +) def test_resolve_token_from_arg(): @@ -15,3 +21,177 @@ def test_resolve_token_from_config(): def test_resolve_token_missing(): with pytest.raises(click.ClickException): resolve_token(None, {}) + + +def test_build_gateway_intents_requests_only_selected_features(): + intents = build_gateway_intents({"reactions", "voice"}) + + assert intents.guilds + assert intents.guild_reactions + assert intents.dm_reactions + assert intents.guild_messages + assert intents.dm_messages + assert intents.voice_states + assert not intents.message_content + assert not intents.members + assert not intents.presences + + +def test_gateway_features_for_filtered_events(): + assert gateway_features_for_events({"reactions", "voice"}) == { + "reactions", + "voice", + } + + +def test_gateway_features_for_all_events(): + assert gateway_features_for_events(None) == { + "messages", + "reactions", + "members", + "voice", + } + + +@pytest.mark.asyncio +async def test_run_rest_action_logs_in_without_starting_gateway(monkeypatch): + class FakeClient: + def __init__(self, *, intents): + self.intents = intents + self.logged_in = False + self.closed = False + + async def login(self, token): + assert token == "token" + self.logged_in = True + + async def start(self, token): + raise AssertionError("REST actions must not start Gateway") + + async def close(self): + self.closed = True + + fake_client = None + + def make_client(*, intents): + nonlocal fake_client + fake_client = FakeClient(intents=intents) + return fake_client + + monkeypatch.setattr("discli.client.discord.Client", make_client) + + async def action(client): + assert client.logged_in + return "ok" + + assert await run_rest_action("token", action) == "ok" + assert fake_client is not None + assert fake_client.closed + + +@pytest.mark.asyncio +async def test_run_rest_action_closes_client_when_action_fails(monkeypatch): + class FakeClient: + def __init__(self, *, intents): + self.closed = False + + async def login(self, token): + pass + + async def close(self): + self.closed = True + + fake_client = None + + def make_client(*, intents): + nonlocal fake_client + fake_client = FakeClient(intents=intents) + return fake_client + + monkeypatch.setattr("discli.client.discord.Client", make_client) + + async def action(client): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + await run_rest_action("token", action) + assert fake_client is not None + assert fake_client.closed + + +@pytest.mark.asyncio +async def test_run_gateway_action_starts_gateway_with_supplied_intents(monkeypatch): + class FakeClient: + def __init__(self, *, intents): + self.intents = intents + self.closed = False + self.on_ready = None + + def event(self, callback): + self.on_ready = callback + return callback + + async def start(self, token): + assert token == "token" + assert self.on_ready is not None + await self.on_ready() + + async def close(self): + self.closed = True + + def is_closed(self): + return self.closed + + fake_client = None + + def make_client(*, intents): + nonlocal fake_client + fake_client = FakeClient(intents=intents) + return fake_client + + monkeypatch.setattr("discli.client.discord.Client", make_client) + intents = build_gateway_intents({"voice"}) + + async def action(client): + return "ready" + + assert await run_gateway_action("token", action, intents) == "ready" + assert fake_client is not None + assert fake_client.intents is intents + assert fake_client.closed + + +@pytest.mark.asyncio +async def test_run_gateway_action_closes_client_when_start_fails(monkeypatch): + class FakeClient: + def __init__(self, *, intents): + self.closed = False + + def event(self, callback): + return callback + + async def start(self, token): + raise RuntimeError("Gateway startup failed") + + async def close(self): + self.closed = True + + def is_closed(self): + return self.closed + + fake_client = None + + def make_client(*, intents): + nonlocal fake_client + fake_client = FakeClient(intents=intents) + return fake_client + + monkeypatch.setattr("discli.client.discord.Client", make_client) + + async def action(client): + raise AssertionError("action must not run before Gateway readiness") + + with pytest.raises(RuntimeError, match="Gateway startup failed"): + await run_gateway_action("token", action, build_gateway_intents()) + assert fake_client is not None + assert fake_client.closed diff --git a/tests/test_rest_commands.py b/tests/test_rest_commands.py new file mode 100644 index 0000000..ba51382 --- /dev/null +++ b/tests/test_rest_commands.py @@ -0,0 +1,44 @@ +from click.testing import CliRunner + +from discli.cli import main + + +def test_reaction_list_uses_http_without_gateway(monkeypatch): + class FakeReaction: + emoji = "ok" + count = 3 + + class FakeMessage: + reactions = [FakeReaction()] + + class FakeChannel: + async def fetch_message(self, message_id): + assert message_id == 456 + return FakeMessage() + + class FakeClient: + def __init__(self, *, intents): + self.closed = False + + async def login(self, token): + assert token == "token" + + async def fetch_channel(self, channel_id): + assert channel_id == 123 + return FakeChannel() + + async def start(self, token): + raise AssertionError("reaction list must not start Gateway") + + async def close(self): + self.closed = True + + monkeypatch.setattr("discli.client.discord.Client", FakeClient) + + result = CliRunner().invoke( + main, + ["--token", "token", "reaction", "list", "123", "456"], + ) + + assert result.exit_code == 0, result.output + assert result.output == "ok x3\n" diff --git a/tests/test_utils.py b/tests/test_utils.py index 49f3133..1165f31 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,18 @@ import json -from discli.utils import format_output +import click +import pytest + +from discli.utils import ( + fetch_guilds, + format_output, + resolve_channel, + resolve_guild, + resolve_member, + resolve_role, + resolve_thread, + resolve_user, +) def test_format_output_plain(): @@ -18,3 +30,165 @@ def test_format_output_json_list(): data = [{"id": 1}, {"id": 2}] result = format_output(data, use_json=True) assert json.loads(result) == data + + +@pytest.mark.asyncio +async def test_resolve_channel_id_uses_http_fetch(): + expected = object() + + class FakeClient: + async def fetch_channel(self, channel_id): + assert channel_id == 123 + return expected + + assert await resolve_channel(FakeClient(), "123") is expected + + +@pytest.mark.asyncio +async def test_fetch_guilds_prefers_gateway_cache(): + expected = object() + + class FakeClient: + guilds = [expected] + + def fetch_guilds(self, *, limit): + raise AssertionError("populated Gateway cache must avoid HTTP") + + assert await fetch_guilds(FakeClient()) == [expected] + + +@pytest.mark.asyncio +async def test_resolve_guild_id_prefers_gateway_cache(): + expected = object() + + class FakeClient: + def get_guild(self, guild_id): + assert guild_id == 123 + return expected + + async def fetch_guild(self, guild_id): + raise AssertionError("cached guild must avoid HTTP") + + assert await resolve_guild(FakeClient(), "123") is expected + + +@pytest.mark.asyncio +async def test_resolve_guild_name_falls_back_to_http_after_cache_miss(): + class FakeGuild: + def __init__(self, guild_id, name): + self.id = guild_id + self.name = name + + expected = FakeGuild(456, "Target") + + class FakeClient: + guilds = [FakeGuild(123, "Cached")] + + async def fetch_guilds(self, *, limit): + assert limit is None + yield expected + + assert await resolve_guild(FakeClient(), "target") is expected + + +@pytest.mark.asyncio +async def test_resolve_channel_id_prefers_gateway_cache(): + expected = object() + + class FakeClient: + def get_channel(self, channel_id): + assert channel_id == 123 + return expected + + async def fetch_channel(self, channel_id): + raise AssertionError("cached channel must avoid HTTP") + + assert await resolve_channel(FakeClient(), "123") is expected + + +@pytest.mark.asyncio +async def test_resolve_member_id_prefers_gateway_cache(): + expected = object() + + class FakeGuild: + def get_member(self, member_id): + assert member_id == 123 + return expected + + async def fetch_member(self, member_id): + raise AssertionError("cached member must avoid HTTP") + + assert await resolve_member(FakeGuild(), "123") is expected + + +@pytest.mark.asyncio +async def test_resolve_user_id_prefers_gateway_cache(): + expected = object() + + class FakeClient: + def get_user(self, user_id): + assert user_id == 123 + return expected + + async def fetch_user(self, user_id): + raise AssertionError("cached user must avoid HTTP") + + assert await resolve_user(FakeClient(), "123") is expected + + +@pytest.mark.asyncio +async def test_resolve_role_name_prefers_gateway_cache(): + class FakeRole: + id = 123 + name = "Moderator" + + expected = FakeRole() + + class FakeGuild: + roles = [expected] + + async def fetch_roles(self): + raise AssertionError("cached role must avoid HTTP") + + assert await resolve_role(FakeGuild(), "moderator") is expected + + +@pytest.mark.asyncio +async def test_resolve_thread_name_prefers_gateway_cache(): + class FakeThread: + id = 123 + name = "Support" + + expected = FakeThread() + + class FakeGuild: + threads = [expected] + + async def active_threads(self): + raise AssertionError("cached thread must avoid HTTP") + + class FakeClient: + guilds = [FakeGuild()] + + assert await resolve_thread(FakeClient(), "support") is expected + + +@pytest.mark.asyncio +async def test_resolve_channel_name_rejects_ambiguous_matches(): + class FakeChannel: + def __init__(self, channel_id): + self.id = channel_id + self.name = "general" + + class FakeGuild: + async def fetch_channels(self): + return [FakeChannel(id(self))] + + class FakeClient: + async def fetch_guilds(self, *, limit): + assert limit is None + for guild in (FakeGuild(), FakeGuild()): + yield guild + + with pytest.raises(click.ClickException, match="Multiple channels"): + await resolve_channel(FakeClient(), "#general") From 7c915ee8a312f05340f1ae643b9e4eb1273d3bee Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 21 Jul 2026 03:50:31 +0800 Subject: [PATCH 2/2] fix(role): make member counts opt-in and correct Forbidden handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two Copilot code-review comments on PR #25 that both touch the per-role member-count logic moved onto the REST-only path. 1) serve.py `_action_role_list` — incorrect counts on Forbidden The `except discord.Forbidden: pass` handler left `member_counts` as the dict of zeros that was initialised before `fetch_members()` ran. When the bot lacked the Server Members intent (403), every role was reported with `members: 0` instead of being marked unavailable. The standalone `role.py` command already reset to `None` on Forbidden, so the serve action was inconsistent and silently wrong. Fixed by setting `member_counts = None` in the handler so counts surface as `null` / "unavailable", matching the CLI command. 2) role.py `role list` — expensive by default `guild.fetch_members(limit=None)` iterates the entire member list to compute per-role counts. On large servers this is slow, rate-limit prone, and requires the privileged Server Members intent — yet it ran on every `role list` invocation of a REST-only command. Made member counts opt-in via a new `--with-member-counts` flag (default off). By default `fetch_members` is not called at all and `members` is reported as `unavailable`/`null`; passing the flag computes counts and, on Forbidden, reports them as unavailable rather than 0. Applied the same opt-in pattern to the serve `role_list` action via a new `with_member_counts` request field (default false) so the bot protocol stays consistent with the CLI and avoids the same whole-member-list scan on every call. The Forbidden fix above now lives inside this opt-in branch, so it only runs when counts are actually requested. Docs: - docs/reference/cli-commands.mdx: document `--with-member-counts` and the new default (counts omitted). - docs/reference/serve-actions.mdx: document `with_member_counts` and that `members` is `null` by default. - agents/discord-agent.md: show both default and `--with-member-counts` invocations. Tests (tests/test_rest_commands.py): - `role list` without the flag does not call `fetch_members` and reports `members: unavailable`. - `role list --with-member-counts` computes counts (Admin: 2, Mod: 1). - `role list --with-member-counts` against a 403 reports `unavailable` for every role and never `0`. Validation: - `uv run --frozen pytest tests/ -v` — 65 passed, 4 skipped (3 new tests added). - `uv build` — source distribution and wheel built successfully. - `git diff --check` — no whitespace errors. --- agents/discord-agent.md | 3 +- docs/reference/cli-commands.mdx | 8 ++- docs/reference/serve-actions.mdx | 5 +- src/discli/commands/role.py | 30 ++++++--- src/discli/commands/serve.py | 22 +++--- tests/test_rest_commands.py | 111 +++++++++++++++++++++++++++++++ 6 files changed, 157 insertions(+), 22 deletions(-) diff --git a/agents/discord-agent.md b/agents/discord-agent.md index 7ce671e..7d4c694 100644 --- a/agents/discord-agent.md +++ b/agents/discord-agent.md @@ -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" discli role edit "server name" "Role" --name "New Name" --color 00ff00 --hoist --mentionable diff --git a/docs/reference/cli-commands.mdx b/docs/reference/cli-commands.mdx index 82b074f..7295188 100644 --- a/docs/reference/cli-commands.mdx +++ b/docs/reference/cli-commands.mdx @@ -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 +discli role list [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. diff --git a/docs/reference/serve-actions.mdx b/docs/reference/serve-actions.mdx index 25c94e2..3c2d0b0 100644 --- a/docs/reference/serve-actions.mdx +++ b/docs/reference/serve-actions.mdx @@ -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:** @@ -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 diff --git a/src/discli/commands/role.py b/src/discli/commands/role.py index db1b44f..fb03e45 100644 --- a/src/discli/commands/role.py +++ b/src/discli/commands/role.py @@ -12,8 +12,17 @@ def role_group(): @role_group.command("list") @click.argument("server") +@click.option( + "--with-member-counts", + is_flag=True, + default=False, + help=( + "Compute per-role member counts. Off by default because it iterates the " + "entire member list (slow on large servers, needs the Server Members intent)." + ), +) @click.pass_context -def role_list(ctx, server): +def role_list(ctx, server, with_member_counts): """List roles in a server.""" def action(client): @@ -21,14 +30,17 @@ async def _action(client): guild = await resolve_guild(client, server) fetched_roles = await guild.fetch_roles() member_counts = None - try: - member_counts = {role.id: 0 for role in fetched_roles} - async for member in guild.fetch_members(limit=None): - for member_role in member.roles: - if member_role.id in member_counts: - member_counts[member_role.id] += 1 - except discord.Forbidden: - member_counts = None + if with_member_counts: + try: + member_counts = {role.id: 0 for role in fetched_roles} + async for member in guild.fetch_members(limit=None): + for member_role in member.roles: + if member_role.id in member_counts: + member_counts[member_role.id] += 1 + except discord.Forbidden: + # Reset to None so counts are reported as unavailable rather + # than silently as 0 for every role. + member_counts = None roles = [ { "id": str(role.id), diff --git a/src/discli/commands/serve.py b/src/discli/commands/serve.py index 32c47ca..d36f307 100644 --- a/src/discli/commands/serve.py +++ b/src/discli/commands/serve.py @@ -1348,14 +1348,20 @@ async def _action_role_list(cmd: dict) -> dict: roles = [] fetched_roles = await guild.fetch_roles() member_counts = None - try: - member_counts = {role.id: 0 for role in fetched_roles} - async for member in guild.fetch_members(limit=None): - for member_role in member.roles: - if member_role.id in member_counts: - member_counts[member_role.id] += 1 - except discord.Forbidden: - pass + # Member counts are opt-in: guild.fetch_members(limit=None) iterates the + # entire member list, which is slow and rate-limit-prone on large servers + # and requires the privileged Server Members intent. Skip it by default. + if cmd.get("with_member_counts"): + try: + member_counts = {role.id: 0 for role in fetched_roles} + async for member in guild.fetch_members(limit=None): + for member_role in member.roles: + if member_role.id in member_counts: + member_counts[member_role.id] += 1 + except discord.Forbidden: + # Reset to None so counts are reported as unavailable rather than + # silently as 0 for every role (the dict was already initialised). + member_counts = None for r in fetched_roles: if r.name == "@everyone": continue diff --git a/tests/test_rest_commands.py b/tests/test_rest_commands.py index ba51382..14f7003 100644 --- a/tests/test_rest_commands.py +++ b/tests/test_rest_commands.py @@ -1,4 +1,5 @@ from click.testing import CliRunner +import discord from discli.cli import main @@ -42,3 +43,113 @@ async def close(self): assert result.exit_code == 0, result.output assert result.output == "ok x3\n" + + +class _FakeRoleResponse: + """Minimal stand-in for discord's HTTP response to build discord.Forbidden.""" + + status = 403 + reason = "Forbidden" + headers = {} + + def json(self): + return {"code": 50001, "message": "Missing Access"} + + @property + def real_url(self): + return "http://example.com" + + +class _FakeRole: + def __init__(self, role_id, name): + self.id = role_id + self.name = name + self.color = "#000000" + + +class _FakeMember: + def __init__(self, role_ids): + self.roles = [type("R", (), {"id": rid})() for rid in role_ids] + + +def _make_role_client(guild): + class FakeClient: + def __init__(self, *, intents): + self.closed = False + + async def login(self, token): + assert token == "token" + + async def fetch_guild(self, guild_id): + assert guild_id == 123 + return guild + + async def start(self, token): + raise AssertionError("role list must not start Gateway") + + async def close(self): + self.closed = True + + return FakeClient + + +def test_role_list_skips_member_counts_by_default(monkeypatch): + class FakeGuild: + async def fetch_roles(self): + return [_FakeRole(1, "Admin"), _FakeRole(2, "Mod")] + + async def fetch_members(self, limit=None): + raise AssertionError("role list must not fetch members by default") + yield # pragma: no cover - makes this an async generator + + monkeypatch.setattr("discli.client.discord.Client", _make_role_client(FakeGuild())) + + result = CliRunner().invoke( + main, ["--token", "token", "role", "list", "123"] + ) + + assert result.exit_code == 0, result.output + assert "members: unavailable" in result.output + assert "Admin (ID: 1" in result.output + assert "Mod (ID: 2" in result.output + + +def test_role_list_with_member_counts(monkeypatch): + class FakeGuild: + async def fetch_roles(self): + return [_FakeRole(1, "Admin"), _FakeRole(2, "Mod")] + + async def fetch_members(self, limit=None): + yield _FakeMember([1]) + yield _FakeMember([1, 2]) + + monkeypatch.setattr("discli.client.discord.Client", _make_role_client(FakeGuild())) + + result = CliRunner().invoke( + main, ["--token", "token", "role", "list", "123", "--with-member-counts"] + ) + + assert result.exit_code == 0, result.output + assert "Admin (ID: 1, color: #000000, members: 2)" in result.output + assert "Mod (ID: 2, color: #000000, members: 1)" in result.output + + +def test_role_list_with_member_counts_forbidden_reports_unavailable(monkeypatch): + class FakeGuild: + async def fetch_roles(self): + return [_FakeRole(1, "Admin"), _FakeRole(2, "Mod")] + + async def fetch_members(self, limit=None): + raise discord.Forbidden(_FakeRoleResponse(), "Missing Access") + yield # pragma: no cover - makes this an async generator + + monkeypatch.setattr("discli.client.discord.Client", _make_role_client(FakeGuild())) + + result = CliRunner().invoke( + main, ["--token", "token", "role", "list", "123", "--with-member-counts"] + ) + + assert result.exit_code == 0, result.output + # Forbidden must surface as "unavailable", not 0, for every role. + assert "members: 0" not in result.output + assert result.output.count("members: unavailable") == 2