discord-tools-cli 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.
- discord_tools/__init__.py +1 -0
- discord_tools/bot.py +96 -0
- discord_tools/cli.py +362 -0
- discord_tools/client.py +235 -0
- discord_tools/config.py +160 -0
- discord_tools/create.py +57 -0
- discord_tools/delete.py +108 -0
- discord_tools/discovery.py +82 -0
- discord_tools/doctor.py +201 -0
- discord_tools/exporters.py +46 -0
- discord_tools/menu.py +592 -0
- discord_tools/models.py +133 -0
- discord_tools/portal.py +133 -0
- discord_tools/prompts.py +170 -0
- discord_tools/records.py +86 -0
- discord_tools/search.py +62 -0
- discord_tools/send.py +86 -0
- discord_tools_cli-0.1.0.dist-info/METADATA +124 -0
- discord_tools_cli-0.1.0.dist-info/RECORD +22 -0
- discord_tools_cli-0.1.0.dist-info/WHEEL +4 -0
- discord_tools_cli-0.1.0.dist-info/entry_points.txt +2 -0
- discord_tools_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
discord_tools/bot.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from discord_tools.models import BotIdentity
|
|
8
|
+
from discord_tools.portal import invite_url
|
|
9
|
+
|
|
10
|
+
RULE = "--------------------------------------------"
|
|
11
|
+
|
|
12
|
+
INTENT_SHOWN = {
|
|
13
|
+
"enabled": "enabled",
|
|
14
|
+
"limited": "limited (may break at scale)",
|
|
15
|
+
"off": "OFF - search/export text will be empty",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class BotChange:
|
|
21
|
+
field: str
|
|
22
|
+
old: str
|
|
23
|
+
new: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def format_bot_profile(identity: BotIdentity, *, profile: str) -> str:
|
|
27
|
+
return "\n".join(
|
|
28
|
+
[
|
|
29
|
+
f"Bot profile {profile!r}",
|
|
30
|
+
RULE,
|
|
31
|
+
f"Username {identity.username}",
|
|
32
|
+
f"Bot ID {identity.id}",
|
|
33
|
+
f"Application {identity.application_id}",
|
|
34
|
+
f"Description {identity.description or '(not set)'}",
|
|
35
|
+
f"Avatar {'set' if identity.has_avatar else 'not set'}",
|
|
36
|
+
f"Content intent {INTENT_SHOWN[identity.message_content_intent]}",
|
|
37
|
+
RULE,
|
|
38
|
+
"Invite URL (adds it to a server you manage):",
|
|
39
|
+
f" {invite_url(identity.application_id)}",
|
|
40
|
+
]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_edit_plan(
|
|
45
|
+
identity: BotIdentity,
|
|
46
|
+
*,
|
|
47
|
+
name: str | None = None,
|
|
48
|
+
description: str | None = None,
|
|
49
|
+
avatar: str | None = None,
|
|
50
|
+
) -> list[BotChange]:
|
|
51
|
+
"""What would actually change, current -> requested. No-ops drop out so
|
|
52
|
+
the confirm diff never claims an edit that is not one."""
|
|
53
|
+
plan: list[BotChange] = []
|
|
54
|
+
# identity.username is "name#0"-style; compare on the bare name.
|
|
55
|
+
current_name = identity.username.split("#")[0]
|
|
56
|
+
if name is not None and name != current_name:
|
|
57
|
+
plan.append(BotChange("username", current_name, name))
|
|
58
|
+
if description is not None and description != (identity.description or ""):
|
|
59
|
+
plan.append(BotChange("description", identity.description or "(not set)", description))
|
|
60
|
+
if avatar is not None:
|
|
61
|
+
if not Path(avatar).is_file():
|
|
62
|
+
# Checked here so a missing file fails before the confirm, not mid-apply.
|
|
63
|
+
raise FileNotFoundError(f"No avatar file at {avatar}.")
|
|
64
|
+
plan.append(BotChange("avatar", "set" if identity.has_avatar else "not set", avatar))
|
|
65
|
+
return plan
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def format_edit_diff(identity: BotIdentity, plan: list[BotChange]) -> str:
|
|
69
|
+
lines = [f"Editing {identity.username} (bot ID {identity.id})", RULE]
|
|
70
|
+
lines.extend(f"{change.field:<12} {change.old} -> {change.new}" for change in plan)
|
|
71
|
+
lines.append(RULE)
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def confirm_bot_edits(diff: str, *, read: Callable[[str], str] = input, write: Callable[[str], None] = print) -> bool:
|
|
76
|
+
write(diff)
|
|
77
|
+
answer = read("Apply these changes? [y/N]: ").strip().lower()
|
|
78
|
+
if not answer:
|
|
79
|
+
write("No answer read - cancelled.")
|
|
80
|
+
return False
|
|
81
|
+
return answer == "y"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def apply_bot_edits(client, plan: list[BotChange]) -> list[str]:
|
|
85
|
+
applied: list[str] = []
|
|
86
|
+
username = next((change.new for change in plan if change.field == "username"), None)
|
|
87
|
+
avatar = next((change.new for change in plan if change.field == "avatar"), None)
|
|
88
|
+
description = next((change.new for change in plan if change.field == "description"), None)
|
|
89
|
+
|
|
90
|
+
if username is not None or avatar is not None:
|
|
91
|
+
await client.edit_bot_user(username=username, avatar_path=avatar)
|
|
92
|
+
applied.extend(change.field for change in plan if change.field in ("username", "avatar"))
|
|
93
|
+
if description is not None:
|
|
94
|
+
await client.edit_application(description=description)
|
|
95
|
+
applied.append("description")
|
|
96
|
+
return applied
|
discord_tools/cli.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from functools import partial
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
from discord_tools.portal import invite_url, run_auth
|
|
12
|
+
from discord_tools.config import ConfigError, load_config
|
|
13
|
+
from discord_tools.discovery import discover_servers, format_tree
|
|
14
|
+
from discord_tools.delete import clear_messages, confirm_clear_messages
|
|
15
|
+
from discord_tools.doctor import run_doctor
|
|
16
|
+
from discord_tools.bot import (
|
|
17
|
+
apply_bot_edits,
|
|
18
|
+
build_edit_plan,
|
|
19
|
+
confirm_bot_edits,
|
|
20
|
+
format_bot_profile,
|
|
21
|
+
format_edit_diff,
|
|
22
|
+
)
|
|
23
|
+
from discord_tools.create import (
|
|
24
|
+
confirm_create,
|
|
25
|
+
create_category,
|
|
26
|
+
create_channel,
|
|
27
|
+
create_thread,
|
|
28
|
+
format_create_preview,
|
|
29
|
+
)
|
|
30
|
+
from discord_tools.exporters import write_records
|
|
31
|
+
from discord_tools.search import all_content_empty, format_message_records, search_messages
|
|
32
|
+
from discord_tools.send import confirm_send, format_send_preview, require_send_allowed, send_to_channel
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def positive_int(value: str) -> int:
|
|
36
|
+
parsed = int(value)
|
|
37
|
+
if parsed < 1:
|
|
38
|
+
raise argparse.ArgumentTypeError("must be at least 1")
|
|
39
|
+
return parsed
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def snowflake(value: str) -> int:
|
|
43
|
+
if not value.isdecimal():
|
|
44
|
+
raise argparse.ArgumentTypeError("must be a numeric Discord ID")
|
|
45
|
+
return int(value)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
49
|
+
parser = argparse.ArgumentParser(prog="discord-tools")
|
|
50
|
+
parser.add_argument("--profile", help="Named bot profile from ~/.discord-tools/ (default: default)")
|
|
51
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
52
|
+
|
|
53
|
+
subparsers.add_parser("auth", help="Guided bot setup: Developer Portal walkthrough, token check, invite URL")
|
|
54
|
+
|
|
55
|
+
doctor = subparsers.add_parser("doctor", help="Check token, message-content intent, servers, and channel permissions")
|
|
56
|
+
doctor.add_argument("--channel", type=snowflake, help="Also check the bot's permissions and message visibility in this channel/thread ID")
|
|
57
|
+
|
|
58
|
+
discover = subparsers.add_parser("discover", help="List the server -> channel -> thread tree with IDs")
|
|
59
|
+
discover.add_argument("--server", type=snowflake, help="Limit to one server ID")
|
|
60
|
+
discover.add_argument("--json", dest="json_output", help="Write the tree to this JSON file instead of printing")
|
|
61
|
+
|
|
62
|
+
search = subparsers.add_parser("search", help="Search and export messages (history fetch + local filter)")
|
|
63
|
+
search.add_argument("--channel", required=True, type=snowflake, help="Channel or thread ID")
|
|
64
|
+
search.add_argument("--keyword", "--contains", dest="keyword", help="Case-insensitive text filter")
|
|
65
|
+
search.add_argument("--from-user", help="Author username or ID")
|
|
66
|
+
search.add_argument("--since", help="Inclusive ISO date or datetime lower bound")
|
|
67
|
+
search.add_argument("--until", help="Inclusive ISO date or datetime upper bound")
|
|
68
|
+
search.add_argument("--limit", type=positive_int, help="Maximum exported messages")
|
|
69
|
+
search.add_argument("--format", choices=("json", "csv"), default="json", help="Export format")
|
|
70
|
+
search.add_argument(
|
|
71
|
+
"--output",
|
|
72
|
+
help="Output file; relative names land in ~/.discord-tools/exports/. Prints a readable table when omitted",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
send_parser = subparsers.add_parser("send", help="Send a message to a channel or thread as the bot")
|
|
76
|
+
send_parser.add_argument("--channel", required=True, type=snowflake, help="Channel or thread ID")
|
|
77
|
+
send_parser.add_argument("--text", help="Message text, or - to read it from stdin; optional when --file is given")
|
|
78
|
+
send_parser.add_argument("--file", dest="files", action="append", metavar="PATH", help="Attach a file; repeatable")
|
|
79
|
+
send_parser.add_argument(
|
|
80
|
+
"--yes",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="Skip the preview and send; the channel must be in DISCORD_SEND_ALLOWLIST",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
create_parser = subparsers.add_parser("create", help="Create a channel, category, or thread")
|
|
86
|
+
create_kinds = create_parser.add_subparsers(dest="create_kind")
|
|
87
|
+
|
|
88
|
+
create_channel_parser = create_kinds.add_parser("channel", help="Create a text channel")
|
|
89
|
+
create_channel_parser.add_argument("--server", required=True, type=snowflake, help="Server ID")
|
|
90
|
+
create_channel_parser.add_argument("--name", required=True, help="Channel name")
|
|
91
|
+
create_channel_parser.add_argument("--category", type=snowflake, help="Category ID to file it under")
|
|
92
|
+
|
|
93
|
+
create_category_parser = create_kinds.add_parser("category", help="Create a category")
|
|
94
|
+
create_category_parser.add_argument("--server", required=True, type=snowflake, help="Server ID")
|
|
95
|
+
create_category_parser.add_argument("--name", required=True, help="Category name")
|
|
96
|
+
|
|
97
|
+
create_thread_parser = create_kinds.add_parser("thread", help="Create a thread in a text channel")
|
|
98
|
+
create_thread_parser.add_argument("--channel", required=True, type=snowflake, help="Parent channel ID")
|
|
99
|
+
create_thread_parser.add_argument("--name", required=True, help="Thread name")
|
|
100
|
+
|
|
101
|
+
for kind_parser in (create_channel_parser, create_category_parser, create_thread_parser):
|
|
102
|
+
kind_parser.add_argument("--yes", action="store_true", help="Skip the confirmation prompt")
|
|
103
|
+
|
|
104
|
+
clear = subparsers.add_parser("clear-messages", help="Clear a channel or thread's messages (dry-run by default)")
|
|
105
|
+
clear.add_argument("--channel", required=True, type=snowflake, help="Channel or thread ID")
|
|
106
|
+
clear.add_argument("--execute", action="store_true", help="Actually clear messages after typing DELETE")
|
|
107
|
+
|
|
108
|
+
bot_parser = subparsers.add_parser("bot", help="Show or edit the active profile's bot settings and invite URL")
|
|
109
|
+
bot_parser.add_argument("--invite", action="store_true", help="Print only the invite URL")
|
|
110
|
+
bot_parser.add_argument("--json", dest="json_output", help="Write the bot profile to this JSON file")
|
|
111
|
+
bot_parser.add_argument("--name", help="Set the bot's username")
|
|
112
|
+
bot_parser.add_argument("--description", help="Set the application description shown on the bot's profile")
|
|
113
|
+
bot_parser.add_argument("--avatar", help="Path to a new avatar image")
|
|
114
|
+
bot_parser.add_argument("--yes", action="store_true", help="Skip the confirmation prompt")
|
|
115
|
+
|
|
116
|
+
return parser
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _write_json(payload, path: str) -> None:
|
|
120
|
+
output = Path(path)
|
|
121
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
output.write_text(json.dumps(payload, indent=2, default=str) + "\n")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _run_discover(client, args) -> int:
|
|
126
|
+
tree = await discover_servers(client, server_id=args.server)
|
|
127
|
+
if args.json_output:
|
|
128
|
+
_write_json(tree, args.json_output)
|
|
129
|
+
else:
|
|
130
|
+
print(format_tree(tree))
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def run(args, *, client=None, config=None) -> int:
|
|
135
|
+
"""Run one command.
|
|
136
|
+
|
|
137
|
+
The menu passes its own already-logged-in client so a whole menu session
|
|
138
|
+
is one login; a caller that passes a client owns it, so it is not closed
|
|
139
|
+
here.
|
|
140
|
+
"""
|
|
141
|
+
if args.command == "auth":
|
|
142
|
+
return await run_auth(profile=args.profile)
|
|
143
|
+
if args.command == "doctor":
|
|
144
|
+
return await run_doctor(profile=args.profile, channel_id=args.channel)
|
|
145
|
+
|
|
146
|
+
if config is None:
|
|
147
|
+
config = load_config(profile=args.profile)
|
|
148
|
+
|
|
149
|
+
if client is not None:
|
|
150
|
+
return await _dispatch(client, args, config)
|
|
151
|
+
from discord_tools.client import open_client
|
|
152
|
+
|
|
153
|
+
async with open_client(config.token) as owned:
|
|
154
|
+
return await _dispatch(owned, args, config)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _run_search(client, args) -> int:
|
|
158
|
+
if args.format == "csv" and not args.output:
|
|
159
|
+
# Checked before the fetch: on a big channel the history walk is the
|
|
160
|
+
# expensive part, and a usage mistake should fail before it, not after.
|
|
161
|
+
raise ValueError("--output is required for CSV export")
|
|
162
|
+
|
|
163
|
+
records = await search_messages(
|
|
164
|
+
client,
|
|
165
|
+
args.channel,
|
|
166
|
+
keyword=args.keyword,
|
|
167
|
+
from_user=args.from_user,
|
|
168
|
+
since=args.since,
|
|
169
|
+
until=args.until,
|
|
170
|
+
limit=args.limit,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if args.output:
|
|
174
|
+
path = write_records(records, args.output, args.format)
|
|
175
|
+
print(f"Exported {len(records)} message(s) to {path}")
|
|
176
|
+
else:
|
|
177
|
+
print(format_message_records(records))
|
|
178
|
+
|
|
179
|
+
if all_content_empty(records):
|
|
180
|
+
print(
|
|
181
|
+
"warning: every fetched message came back with empty text - the classic sign the "
|
|
182
|
+
"message-content intent is off in the Developer Portal. Run `discord-tools doctor`.",
|
|
183
|
+
file=sys.stderr,
|
|
184
|
+
)
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _message_text(raw: str | None, *, has_files: bool) -> str | None:
|
|
189
|
+
# `-` is how a multi-line body gets in: quoting newlines through a shell
|
|
190
|
+
# flag is the kind of thing that silently sends half a message.
|
|
191
|
+
if raw is None:
|
|
192
|
+
if not has_files:
|
|
193
|
+
raise ValueError("Nothing to send: pass --text, or --file to send an attachment.")
|
|
194
|
+
return None
|
|
195
|
+
text = (sys.stdin.read() if raw == "-" else raw).strip()
|
|
196
|
+
if not text and not has_files:
|
|
197
|
+
raise ValueError("Nothing to send: the message text is empty.")
|
|
198
|
+
return text or None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _attachments(paths: list[str] | None) -> list[str]:
|
|
202
|
+
# Checked before the confirm, never mid-send: a typo in the fourth path
|
|
203
|
+
# should not surface after the first three have already reached Discord.
|
|
204
|
+
files = list(paths or [])
|
|
205
|
+
missing = [path for path in files if not Path(path).is_file()]
|
|
206
|
+
if missing:
|
|
207
|
+
raise FileNotFoundError("No file at " + ", ".join(missing) + ".")
|
|
208
|
+
return files
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def _run_send(client, args, config) -> int:
|
|
212
|
+
files = _attachments(getattr(args, "files", None))
|
|
213
|
+
text = _message_text(args.text, has_files=bool(files))
|
|
214
|
+
channel = await client.get_channel(args.channel)
|
|
215
|
+
|
|
216
|
+
confirm = None
|
|
217
|
+
if args.yes:
|
|
218
|
+
require_send_allowed(config.send_allowlist, channel.id)
|
|
219
|
+
else:
|
|
220
|
+
identity = await client.get_identity()
|
|
221
|
+
preview = format_send_preview(channel, text, sender=identity.username, files=files)
|
|
222
|
+
confirm = partial(confirm_send, preview)
|
|
223
|
+
|
|
224
|
+
result = await send_to_channel(client, channel, text, files=files, confirm=confirm)
|
|
225
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
226
|
+
return 1 if result.cancelled else 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def _run_create(client, args) -> int:
|
|
230
|
+
if args.create_kind is None:
|
|
231
|
+
raise ValueError("create needs one of: channel, category, thread.")
|
|
232
|
+
|
|
233
|
+
if args.create_kind == "thread":
|
|
234
|
+
parent = await client.get_channel(args.channel)
|
|
235
|
+
where = f"in #{parent.name} ({parent.id})"
|
|
236
|
+
else:
|
|
237
|
+
server = next((entry for entry in await client.list_servers() if entry.id == args.server), None)
|
|
238
|
+
if server is None:
|
|
239
|
+
raise ValueError(f"The bot is not in a server with ID {args.server}.")
|
|
240
|
+
where = f"in server {server.name} ({server.id})"
|
|
241
|
+
if args.create_kind == "channel" and args.category:
|
|
242
|
+
where += f", under category {args.category}"
|
|
243
|
+
|
|
244
|
+
confirm = None
|
|
245
|
+
if not args.yes:
|
|
246
|
+
preview = format_create_preview(args.create_kind, args.name, where=where)
|
|
247
|
+
confirm = partial(confirm_create, preview)
|
|
248
|
+
|
|
249
|
+
if args.create_kind == "channel":
|
|
250
|
+
created = await create_channel(client, args.server, args.name, category_id=args.category, confirm=confirm)
|
|
251
|
+
elif args.create_kind == "category":
|
|
252
|
+
created = await create_category(client, args.server, args.name, confirm=confirm)
|
|
253
|
+
else:
|
|
254
|
+
created = await create_thread(client, args.channel, args.name, confirm=confirm)
|
|
255
|
+
|
|
256
|
+
print(json.dumps(created.to_dict(), indent=2))
|
|
257
|
+
return 1 if created.cancelled else 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def _run_clear_messages(client, args) -> int:
|
|
261
|
+
result = await clear_messages(
|
|
262
|
+
client,
|
|
263
|
+
args.channel,
|
|
264
|
+
execute=args.execute,
|
|
265
|
+
confirm=confirm_clear_messages,
|
|
266
|
+
progress=print,
|
|
267
|
+
)
|
|
268
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
269
|
+
return 1 if result.cancelled else 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def _run_bot(client, args, config) -> int:
|
|
273
|
+
identity = await client.get_identity()
|
|
274
|
+
|
|
275
|
+
if args.invite:
|
|
276
|
+
print(invite_url(identity.application_id))
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
requested = {key: getattr(args, key) for key in ("name", "description", "avatar") if getattr(args, key) is not None}
|
|
280
|
+
if not requested:
|
|
281
|
+
if args.json_output:
|
|
282
|
+
_write_json(identity.to_dict(), args.json_output)
|
|
283
|
+
else:
|
|
284
|
+
print(format_bot_profile(identity, profile=config.profile))
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
plan = build_edit_plan(identity, **requested)
|
|
288
|
+
if not plan:
|
|
289
|
+
print("Nothing to change - every requested value is already set.")
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
if not args.yes:
|
|
293
|
+
if not confirm_bot_edits(format_edit_diff(identity, plan)):
|
|
294
|
+
print(json.dumps({"applied": [], "cancelled": True}, indent=2))
|
|
295
|
+
return 1
|
|
296
|
+
|
|
297
|
+
applied = await apply_bot_edits(client, plan)
|
|
298
|
+
print(json.dumps({"applied": applied, "cancelled": False}, indent=2))
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def _dispatch(client, args, config) -> int:
|
|
303
|
+
if args.command == "discover":
|
|
304
|
+
return await _run_discover(client, args)
|
|
305
|
+
if args.command == "search":
|
|
306
|
+
return await _run_search(client, args)
|
|
307
|
+
if args.command == "send":
|
|
308
|
+
return await _run_send(client, args, config)
|
|
309
|
+
if args.command == "create":
|
|
310
|
+
return await _run_create(client, args)
|
|
311
|
+
if args.command == "clear-messages":
|
|
312
|
+
return await _run_clear_messages(client, args)
|
|
313
|
+
if args.command == "bot":
|
|
314
|
+
return await _run_bot(client, args, config)
|
|
315
|
+
raise ValueError(f"Unknown command: {args.command}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
319
|
+
parser = build_parser()
|
|
320
|
+
args = parser.parse_args(argv)
|
|
321
|
+
try:
|
|
322
|
+
if args.command is None:
|
|
323
|
+
if not sys.stdin.isatty():
|
|
324
|
+
# A menu needs a human. Scripts and agents get the help they
|
|
325
|
+
# actually wanted instead of a blocked input() prompt.
|
|
326
|
+
parser.print_help()
|
|
327
|
+
return 0
|
|
328
|
+
try:
|
|
329
|
+
# `input()` only gets line editing when readline is imported.
|
|
330
|
+
# Without it every arrow key echoes its raw escape sequence
|
|
331
|
+
# (^[[A) into the answer. Menu-only, and optional: readline is
|
|
332
|
+
# absent on some platforms and the menu works fine without it.
|
|
333
|
+
import readline # noqa: F401
|
|
334
|
+
except ImportError:
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
# Imported here, not at module scope: menu.py imports cli, and a
|
|
338
|
+
# top-level import either way closes the cycle.
|
|
339
|
+
from discord_tools.menu import run_menu
|
|
340
|
+
|
|
341
|
+
return asyncio.run(run_menu(profile=args.profile))
|
|
342
|
+
return asyncio.run(run(args))
|
|
343
|
+
except (KeyboardInterrupt, EOFError):
|
|
344
|
+
print()
|
|
345
|
+
return 130
|
|
346
|
+
except ConfigError as exc:
|
|
347
|
+
parser.error(str(exc))
|
|
348
|
+
except ValueError as exc:
|
|
349
|
+
parser.error(str(exc))
|
|
350
|
+
except PermissionError as exc:
|
|
351
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
352
|
+
return 2
|
|
353
|
+
except RuntimeError as exc:
|
|
354
|
+
# ClientError and friends: a Discord-side refusal, not a usage mistake.
|
|
355
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
356
|
+
return 2
|
|
357
|
+
except OSError as exc:
|
|
358
|
+
parser.error(str(exc))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == "__main__":
|
|
362
|
+
raise SystemExit(main())
|