discordcli-agents 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """discord-cli: command-line tool for managing Discord servers and AI-driven automation."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running the package as `python -m discord_cli`."""
2
+
3
+ from discord_cli.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
discord_cli/cli.py ADDED
@@ -0,0 +1,48 @@
1
+ """Command-line entry point for discord-cli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from discord_cli import __version__
8
+
9
+ # Import all command modules to register them
10
+ from discord_cli.commands import (
11
+ categories,
12
+ channels,
13
+ export,
14
+ guilds,
15
+ invites,
16
+ members,
17
+ messages,
18
+ permissions,
19
+ roles,
20
+ search,
21
+ threads,
22
+ webhooks,
23
+ )
24
+
25
+
26
+ @click.group()
27
+ @click.version_option(version=__version__, prog_name="discord-cli")
28
+ def main() -> None:
29
+ """Command-line tool for managing Discord servers and automating Discord via AI agents."""
30
+
31
+
32
+ # Register all command groups
33
+ main.add_command(channels.channel)
34
+ main.add_command(categories.category)
35
+ main.add_command(roles.role)
36
+ main.add_command(members.member)
37
+ main.add_command(messages.message)
38
+ main.add_command(guilds.guild)
39
+ main.add_command(permissions.permissions)
40
+ main.add_command(webhooks.webhook)
41
+ main.add_command(invites.invites)
42
+ main.add_command(threads.threads)
43
+ main.add_command(search.search)
44
+ main.add_command(export.export)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
discord_cli/client.py ADDED
@@ -0,0 +1,102 @@
1
+ """Discord bot lifecycle: connect, run a single action, print the result, exit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator, Awaitable
7
+ from contextlib import asynccontextmanager
8
+ from typing import Any, Callable
9
+
10
+ import discord
11
+
12
+ from discord_cli.config import load_config
13
+ from discord_cli.errors import DiscordCliError
14
+ from discord_cli.output import make_payload, print_output
15
+
16
+ Action = Callable[..., Awaitable[Any]]
17
+
18
+
19
+ class ManagementClient(discord.Client):
20
+ """A discord.Client that runs a single action on_ready and then closes."""
21
+
22
+ def __init__(self, action: Action, kwargs: dict[str, Any], intents: discord.Intents):
23
+ super().__init__(intents=intents)
24
+ self.action = action
25
+ self.kwargs = kwargs
26
+ self.result: Any = None
27
+ self.error: dict[str, Any] | None = None
28
+
29
+ async def on_ready(self) -> None:
30
+ try:
31
+ self.result = await self.action(self, **self.kwargs)
32
+ except Exception as exc: # noqa: BLE001 - user-facing CLI boundary
33
+ self.error = {"type": exc.__class__.__name__, "message": str(exc)}
34
+ if isinstance(exc, discord.HTTPException):
35
+ self.error.update({"status": exc.status, "code": exc.code})
36
+ finally:
37
+ await self.close()
38
+
39
+
40
+ @asynccontextmanager
41
+ async def bot_session(client: ManagementClient, token: str) -> AsyncIterator[ManagementClient]:
42
+ """Connect the client, let it run its action to completion, and guarantee it closes."""
43
+ try:
44
+ await client.start(token)
45
+ yield client
46
+ finally:
47
+ if not client.is_closed():
48
+ await client.close()
49
+
50
+
51
+ def fallback_intents() -> discord.Intents:
52
+ intents = discord.Intents.default()
53
+ intents.guilds = True
54
+ intents.reactions = True
55
+ return intents
56
+
57
+
58
+ async def _run_async(
59
+ action: Action, kwargs: dict[str, Any], token: str
60
+ ) -> tuple[Any, dict[str, Any] | None]:
61
+ last_error: dict[str, Any] | None = None
62
+ for intents in (discord.Intents.all(), fallback_intents()):
63
+ client = ManagementClient(action, kwargs, intents)
64
+ try:
65
+ async with bot_session(client, token) as session:
66
+ result, error = session.result, session.error
67
+ except discord.PrivilegedIntentsRequired as exc:
68
+ last_error = {"type": exc.__class__.__name__, "message": str(exc)}
69
+ continue
70
+ except Exception as exc: # noqa: BLE001 - user-facing CLI boundary
71
+ return None, {"type": exc.__class__.__name__, "message": str(exc)}
72
+ if last_error and error is None and isinstance(result, dict):
73
+ result.setdefault(
74
+ "warning",
75
+ "Retried with non-privileged intents because privileged intents are not enabled for this bot.",
76
+ )
77
+ return result, error
78
+ return None, last_error
79
+
80
+
81
+ def run_bot(
82
+ action: Action, kwargs: dict[str, Any], token: str
83
+ ) -> tuple[Any, dict[str, Any] | None]:
84
+ """Run `action` against a freshly connected bot and return (result, error)."""
85
+ return asyncio.run(_run_async(action, kwargs, token))
86
+
87
+
88
+ def run_action(
89
+ action: Action, kwargs: dict[str, Any], human: bool, token: str | None = None
90
+ ) -> None:
91
+ """Load config, run the action against the bot, print the payload, and exit with its status."""
92
+ try:
93
+ bot_token = load_config(token).bot_token
94
+ except DiscordCliError as exc:
95
+ payload = make_payload(error={"type": exc.__class__.__name__, "message": str(exc)})
96
+ print_output(payload, human)
97
+ raise SystemExit(1) from None
98
+
99
+ result, error = run_bot(action, kwargs, bot_token)
100
+ payload = make_payload(result, error)
101
+ print_output(payload, human)
102
+ raise SystemExit(0 if payload["ok"] else 1)
@@ -0,0 +1 @@
1
+ """Command implementations for discord-cli, grouped by resource (channel, role, member, ...)."""
@@ -0,0 +1,93 @@
1
+ """Category commands: list, create, delete, edit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+ import discord
9
+
10
+ from discord_cli.errors import CliError
11
+ from discord_cli.models import channel_to_dict, get_category, get_guild
12
+ from discord_cli.registry import invoke, registry
13
+
14
+
15
+ @registry.group("category")
16
+ def category_group() -> None:
17
+ """Manage channel categories."""
18
+
19
+
20
+ @category_group.command("list")
21
+ @click.pass_context
22
+ def category_list(ctx: click.Context) -> None:
23
+ invoke(ctx, action_category_list)
24
+
25
+
26
+ @category_group.command("create")
27
+ @click.argument("name")
28
+ @click.option("--position", type=int, default=None)
29
+ @click.pass_context
30
+ def category_create(ctx: click.Context, name: str, position: int | None) -> None:
31
+ invoke(ctx, action_category_create, name=name, position=position)
32
+
33
+
34
+ @category_group.command("delete")
35
+ @click.argument("category_id", type=int)
36
+ @click.pass_context
37
+ def category_delete(ctx: click.Context, category_id: int) -> None:
38
+ invoke(ctx, action_category_delete, category_id=category_id)
39
+
40
+
41
+ @category_group.command("edit")
42
+ @click.argument("category_id", type=int)
43
+ @click.option("--name", default=None)
44
+ @click.option("--position", type=int, default=None)
45
+ @click.pass_context
46
+ def category_edit(
47
+ ctx: click.Context, category_id: int, name: str | None, position: int | None
48
+ ) -> None:
49
+ invoke(ctx, action_category_edit, category_id=category_id, name=name, position=position)
50
+
51
+
52
+ async def action_category_list(client: discord.Client, guild_id: int | None, **_: Any) -> Any:
53
+ guild = await get_guild(client, guild_id)
54
+ return {
55
+ "guild_id": guild.id,
56
+ "categories": [channel_to_dict(c, detailed=True) for c in guild.categories],
57
+ }
58
+
59
+
60
+ async def action_category_create(
61
+ client: discord.Client, guild_id: int | None, name: str, position: int | None, **_: Any
62
+ ) -> Any:
63
+ guild = await get_guild(client, guild_id)
64
+ category = await guild.create_category(
65
+ name, position=position, reason="discord_cli category create"
66
+ )
67
+ return {"created": channel_to_dict(category, detailed=True)}
68
+
69
+
70
+ async def action_category_delete(
71
+ client: discord.Client, guild_id: int | None, category_id: int, **_: Any
72
+ ) -> Any:
73
+ guild = await get_guild(client, guild_id)
74
+ category = get_category(guild, category_id)
75
+ data = channel_to_dict(category)
76
+ await category.delete(reason="discord_cli category delete")
77
+ return {"deleted": data}
78
+
79
+
80
+ async def action_category_edit(
81
+ client: discord.Client, guild_id: int | None, category_id: int, **kwargs: Any
82
+ ) -> Any:
83
+ guild = await get_guild(client, guild_id)
84
+ category = get_category(guild, category_id)
85
+ edits = {
86
+ k: v
87
+ for k, v in {"name": kwargs.get("name"), "position": kwargs.get("position")}.items()
88
+ if v is not None
89
+ }
90
+ if not edits:
91
+ raise CliError("No edits provided")
92
+ await category.edit(**edits, reason="discord_cli category edit")
93
+ return {"updated": channel_to_dict(category, detailed=True)}
@@ -0,0 +1,198 @@
1
+ """Channel commands: list, create, delete, edit, move, info."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+ import discord
9
+
10
+ from discord_cli.errors import CliError
11
+ from discord_cli.models import channel_to_dict, get_category, get_channel, get_guild
12
+ from discord_cli.registry import TRI_STATE_BOOL, invoke, registry
13
+
14
+
15
+ @registry.group("channel")
16
+ def channel_group() -> None:
17
+ """Manage guild channels."""
18
+
19
+
20
+ @channel_group.command("list")
21
+ @click.pass_context
22
+ def channel_list(ctx: click.Context) -> None:
23
+ invoke(ctx, action_channel_list)
24
+
25
+
26
+ @channel_group.command("create")
27
+ @click.argument("name")
28
+ @click.option(
29
+ "--type", "channel_type", type=click.Choice(["text", "voice", "stage", "forum"]), default="text"
30
+ )
31
+ @click.option("--category", type=int, default=None)
32
+ @click.option("--topic", default=None)
33
+ @click.option("--position", type=int, default=None)
34
+ @click.option("--nsfw", is_flag=True, default=False)
35
+ @click.option("--slowmode", type=int, default=None)
36
+ @click.pass_context
37
+ def channel_create(
38
+ ctx: click.Context,
39
+ name: str,
40
+ channel_type: str,
41
+ category: int | None,
42
+ topic: str | None,
43
+ position: int | None,
44
+ nsfw: bool,
45
+ slowmode: int | None,
46
+ ) -> None:
47
+ invoke(
48
+ ctx,
49
+ action_channel_create,
50
+ name=name,
51
+ type=channel_type,
52
+ category=category,
53
+ topic=topic,
54
+ position=position,
55
+ nsfw=nsfw,
56
+ slowmode=slowmode,
57
+ )
58
+
59
+
60
+ @channel_group.command("delete")
61
+ @click.argument("channel_id", type=int)
62
+ @click.pass_context
63
+ def channel_delete(ctx: click.Context, channel_id: int) -> None:
64
+ invoke(ctx, action_channel_delete, channel_id=channel_id)
65
+
66
+
67
+ @channel_group.command("edit")
68
+ @click.argument("channel_id", type=int)
69
+ @click.option("--name", default=None)
70
+ @click.option("--topic", default=None)
71
+ @click.option("--position", type=int, default=None)
72
+ @click.option("--slowmode", type=int, default=None)
73
+ @click.option("--nsfw", type=TRI_STATE_BOOL, default=None)
74
+ @click.pass_context
75
+ def channel_edit(
76
+ ctx: click.Context,
77
+ channel_id: int,
78
+ name: str | None,
79
+ topic: str | None,
80
+ position: int | None,
81
+ slowmode: int | None,
82
+ nsfw: bool | None,
83
+ ) -> None:
84
+ invoke(
85
+ ctx,
86
+ action_channel_edit,
87
+ channel_id=channel_id,
88
+ name=name,
89
+ topic=topic,
90
+ position=position,
91
+ slowmode=slowmode,
92
+ nsfw=nsfw,
93
+ )
94
+
95
+
96
+ @channel_group.command("move")
97
+ @click.argument("channel_id", type=int)
98
+ @click.option("--category", type=int, required=True)
99
+ @click.pass_context
100
+ def channel_move(ctx: click.Context, channel_id: int, category: int) -> None:
101
+ invoke(ctx, action_channel_move, channel_id=channel_id, category=category)
102
+
103
+
104
+ @channel_group.command("info")
105
+ @click.argument("channel_id", type=int)
106
+ @click.pass_context
107
+ def channel_info(ctx: click.Context, channel_id: int) -> None:
108
+ invoke(ctx, action_channel_info, channel_id=channel_id)
109
+
110
+
111
+ async def action_channel_list(client: discord.Client, guild_id: int | None, **_: Any) -> Any:
112
+ guild = await get_guild(client, guild_id)
113
+ return {"guild_id": guild.id, "channels": [channel_to_dict(c) for c in guild.channels]}
114
+
115
+
116
+ async def action_channel_create(client: discord.Client, guild_id: int | None, **kwargs: Any) -> Any:
117
+ guild = await get_guild(client, guild_id)
118
+ category = get_category(guild, kwargs.get("category"))
119
+ common = {
120
+ "category": category,
121
+ "position": kwargs.get("position"),
122
+ "reason": "discord_cli channel create",
123
+ }
124
+ common = {k: v for k, v in common.items() if v is not None}
125
+ ctype = kwargs.get("type") or "text"
126
+ name = kwargs["name"]
127
+ if ctype == "text":
128
+ channel = await guild.create_text_channel(
129
+ name,
130
+ topic=kwargs.get("topic"),
131
+ nsfw=kwargs.get("nsfw", False),
132
+ slowmode_delay=kwargs.get("slowmode") or 0,
133
+ **common,
134
+ )
135
+ elif ctype == "voice":
136
+ channel = await guild.create_voice_channel(name, **common)
137
+ elif ctype == "stage":
138
+ channel = await guild.create_stage_channel(name, **common)
139
+ elif ctype == "forum":
140
+ channel = await guild.create_forum_channel(
141
+ name,
142
+ topic=kwargs.get("topic"),
143
+ nsfw=kwargs.get("nsfw", False),
144
+ slowmode_delay=kwargs.get("slowmode") or 0,
145
+ **common,
146
+ )
147
+ else:
148
+ raise CliError(f"Unsupported channel type: {ctype}")
149
+ return {"created": channel_to_dict(channel, detailed=True)}
150
+
151
+
152
+ async def action_channel_delete(
153
+ client: discord.Client, guild_id: int | None, channel_id: int, **_: Any
154
+ ) -> Any:
155
+ guild = await get_guild(client, guild_id)
156
+ channel = get_channel(guild, channel_id)
157
+ data = channel_to_dict(channel)
158
+ await channel.delete(reason="discord_cli channel delete")
159
+ return {"deleted": data}
160
+
161
+
162
+ async def action_channel_edit(
163
+ client: discord.Client, guild_id: int | None, channel_id: int, **kwargs: Any
164
+ ) -> Any:
165
+ guild = await get_guild(client, guild_id)
166
+ channel = get_channel(guild, channel_id)
167
+ edits: dict[str, Any] = {}
168
+ if kwargs.get("name") is not None:
169
+ edits["name"] = kwargs["name"]
170
+ if kwargs.get("topic") is not None and hasattr(channel, "topic"):
171
+ edits["topic"] = kwargs["topic"]
172
+ if kwargs.get("position") is not None:
173
+ edits["position"] = kwargs["position"]
174
+ if kwargs.get("slowmode") is not None and hasattr(channel, "slowmode_delay"):
175
+ edits["slowmode_delay"] = kwargs["slowmode"]
176
+ if kwargs.get("nsfw") is not None and hasattr(channel, "nsfw"):
177
+ edits["nsfw"] = kwargs["nsfw"]
178
+ if not edits:
179
+ raise CliError("No edits provided")
180
+ edited = await channel.edit(**edits, reason="discord_cli channel edit")
181
+ return {"updated": channel_to_dict(edited or channel, detailed=True)}
182
+
183
+
184
+ async def action_channel_move(
185
+ client: discord.Client, guild_id: int | None, channel_id: int, category: int, **_: Any
186
+ ) -> Any:
187
+ guild = await get_guild(client, guild_id)
188
+ channel = get_channel(guild, channel_id)
189
+ new_category = get_category(guild, category)
190
+ await channel.edit(category=new_category, reason="discord_cli channel move")
191
+ return {"updated": channel_to_dict(channel, detailed=True)}
192
+
193
+
194
+ async def action_channel_info(
195
+ client: discord.Client, guild_id: int | None, channel_id: int, **_: Any
196
+ ) -> Any:
197
+ guild = await get_guild(client, guild_id)
198
+ return channel_to_dict(get_channel(guild, channel_id), detailed=True)
@@ -0,0 +1,66 @@
1
+ """Export commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+
8
+ import click
9
+
10
+ from ..client import run_bot
11
+ from ..output import output
12
+
13
+
14
+ @click.group()
15
+ def export() -> None:
16
+ """Export channel data."""
17
+
18
+
19
+ @export.command("channel")
20
+ @click.argument("channel_id", type=int)
21
+ @click.option("--limit", type=int, default=100, help="Number of messages to export")
22
+ @click.option("--format", type=click.Choice(["json", "csv"]), default="json")
23
+ @click.pass_context
24
+ def export_channel(ctx: click.Context, channel_id: int, limit: int, fmt: str) -> None:
25
+ """Export messages from a channel."""
26
+
27
+ async def _action(client, **kwargs):
28
+ guild = client.guilds[0]
29
+ channel = guild.get_channel(channel_id)
30
+ if not channel:
31
+ raise ValueError(f"Channel {channel_id} not found")
32
+
33
+ messages = []
34
+ async for msg in channel.history(limit=limit):
35
+ messages.append(
36
+ {
37
+ "id": msg.id,
38
+ "author_id": msg.author.id,
39
+ "author_name": msg.author.name,
40
+ "content": msg.content,
41
+ "created_at": msg.created_at.isoformat(),
42
+ "edited_at": msg.edited_at.isoformat() if msg.edited_at else None,
43
+ "attachments": [
44
+ {"url": a.url, "filename": a.filename, "content_type": a.content_type}
45
+ for a in msg.attachments
46
+ ],
47
+ "embeds": len(msg.embeds),
48
+ "pinned": msg.pinned,
49
+ }
50
+ )
51
+
52
+ if fmt == "csv":
53
+ output_buf = io.StringIO()
54
+ writer = csv.DictWriter(
55
+ output_buf,
56
+ fieldnames=["id", "author_id", "author_name", "content", "created_at", "pinned"],
57
+ )
58
+ writer.writeheader()
59
+ for m in messages:
60
+ writer.writerow({k: m.get(k, "") for k in writer.fieldnames})
61
+ return {"format": "csv", "count": len(messages), "data": output_buf.getvalue()}
62
+ else:
63
+ return {"format": "json", "count": len(messages), "messages": messages}
64
+
65
+ result, error = run_bot(_action, ctx=ctx)
66
+ output(result, error, ctx)