bot-cmder 0.2.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.
- bot_cmder/__init__.py +10 -0
- bot_cmder/__main__.py +15 -0
- bot_cmder/adapters/__init__.py +0 -0
- bot_cmder/adapters/base.py +23 -0
- bot_cmder/adapters/discord/__init__.py +5 -0
- bot_cmder/adapters/discord/adapter.py +203 -0
- bot_cmder/adapters/discord/client.py +126 -0
- bot_cmder/adapters/discord/gateway.py +483 -0
- bot_cmder/adapters/discord/router.py +122 -0
- bot_cmder/adapters/discord/schemas.py +151 -0
- bot_cmder/adapters/slack/__init__.py +5 -0
- bot_cmder/adapters/slack/adapter.py +143 -0
- bot_cmder/adapters/slack/client.py +89 -0
- bot_cmder/adapters/slack/router.py +149 -0
- bot_cmder/adapters/slack/schemas.py +59 -0
- bot_cmder/adapters/slack/signing.py +88 -0
- bot_cmder/adapters/slack/socket.py +254 -0
- bot_cmder/adapters/telegram/__init__.py +5 -0
- bot_cmder/adapters/telegram/adapter.py +54 -0
- bot_cmder/adapters/telegram/client.py +153 -0
- bot_cmder/adapters/telegram/daemon.py +201 -0
- bot_cmder/adapters/telegram/router.py +63 -0
- bot_cmder/adapters/telegram/schemas.py +43 -0
- bot_cmder/audit/__init__.py +0 -0
- bot_cmder/audit/log.py +240 -0
- bot_cmder/auth/__init__.py +0 -0
- bot_cmder/auth/acl.py +44 -0
- bot_cmder/auth/emergency.py +173 -0
- bot_cmder/auth/lockout.py +202 -0
- bot_cmder/auth/lockout_store.py +168 -0
- bot_cmder/auth/migrations/0001_initial.sql +10 -0
- bot_cmder/auth/migrations/0002_lockout.sql +37 -0
- bot_cmder/auth/migrations/__init__.py +0 -0
- bot_cmder/auth/pending.py +77 -0
- bot_cmder/auth/secret_store.py +124 -0
- bot_cmder/auth/totp.py +65 -0
- bot_cmder/cli/__init__.py +59 -0
- bot_cmder/cli/discord_register.py +269 -0
- bot_cmder/cli/init_cmd.py +178 -0
- bot_cmder/cli/keys.py +38 -0
- bot_cmder/cli/serve.py +118 -0
- bot_cmder/cli/slack_manifest.py +226 -0
- bot_cmder/cli/totp.py +157 -0
- bot_cmder/commands/__init__.py +0 -0
- bot_cmder/commands/builtin/__init__.py +104 -0
- bot_cmder/commands/builtin/health.py +70 -0
- bot_cmder/commands/builtin/help.py +28 -0
- bot_cmder/commands/builtin/kubectl.py +68 -0
- bot_cmder/commands/builtin/otp.py +498 -0
- bot_cmder/commands/builtin/runbook.py +123 -0
- bot_cmder/commands/builtin/service.py +388 -0
- bot_cmder/commands/builtin/ssh.py +129 -0
- bot_cmder/commands/builtin/whoami.py +28 -0
- bot_cmder/config/__init__.py +0 -0
- bot_cmder/config/paths.py +106 -0
- bot_cmder/config/schema.py +365 -0
- bot_cmder/config/settings.py +218 -0
- bot_cmder/connectors/__init__.py +0 -0
- bot_cmder/connectors/base.py +39 -0
- bot_cmder/connectors/local.py +73 -0
- bot_cmder/connectors/ssh.py +203 -0
- bot_cmder/core/__init__.py +0 -0
- bot_cmder/core/context.py +37 -0
- bot_cmder/core/dispatcher.py +209 -0
- bot_cmder/core/errors.py +22 -0
- bot_cmder/core/events.py +82 -0
- bot_cmder/core/parser.py +47 -0
- bot_cmder/core/redact.py +81 -0
- bot_cmder/core/registry.py +213 -0
- bot_cmder/data/__init__.py +8 -0
- bot_cmder/data/app.yaml.example +317 -0
- bot_cmder/main.py +440 -0
- bot_cmder/storage/__init__.py +0 -0
- bot_cmder/storage/migrator.py +178 -0
- bot_cmder-0.2.0.dist-info/METADATA +151 -0
- bot_cmder-0.2.0.dist-info/RECORD +79 -0
- bot_cmder-0.2.0.dist-info/WHEEL +4 -0
- bot_cmder-0.2.0.dist-info/entry_points.txt +2 -0
- bot_cmder-0.2.0.dist-info/licenses/LICENSE +21 -0
bot_cmder/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""bot-cmder — multi-platform SRE ChatOps bot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Single source of truth for the package version. `pyproject.toml`
|
|
6
|
+
# reads this via `[tool.hatch.version] path = "bot_cmder/__init__.py"`,
|
|
7
|
+
# `bot_cmder/main.py` passes it to FastAPI's OpenAPI metadata, and the
|
|
8
|
+
# CLI dispatcher prints it for `bot-cmder --version`. Bump here before
|
|
9
|
+
# tagging a release; everything downstream picks up automatically.
|
|
10
|
+
__version__ = "0.2.0"
|
bot_cmder/__main__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""`python -m bot_cmder` entry point — delegates to the CLI dispatcher.
|
|
2
|
+
|
|
3
|
+
Pairs with the console-script `bot-cmder` declared in `pyproject.toml`'s
|
|
4
|
+
`[project.scripts]`. Both invocations end up in `bot_cmder.cli.main`,
|
|
5
|
+
so `python -m bot_cmder serve` and `bot-cmder serve` are equivalent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from bot_cmder.cli import main
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from bot_cmder.core.events import IncomingMessage, OutgoingResponse, Platform
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PlatformAdapter(ABC):
|
|
10
|
+
"""How a chat platform talks to bot-cmder.
|
|
11
|
+
|
|
12
|
+
Each adapter owns its own incoming-payload schema and outbound
|
|
13
|
+
client; the only contract here is the conversion in/out of the
|
|
14
|
+
platform-neutral IncomingMessage / OutgoingResponse types.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
platform: Platform
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def parse(self, raw: Any) -> IncomingMessage | None: ...
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def send(self, msg: IncomingMessage, resp: OutgoingResponse) -> None: ...
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""DiscordAdapter — Discord Interactions OR Gateway events → IncomingMessages.
|
|
2
|
+
|
|
3
|
+
Two ingestion paths feed the same dispatcher:
|
|
4
|
+
|
|
5
|
+
- **HTTP Interactions** (Phase 4, default): slash commands arrive
|
|
6
|
+
as the `Interaction` envelope. Schema is intentionally flat —
|
|
7
|
+
one Discord application command per top-level chat command, with
|
|
8
|
+
a single optional `args` STRING option. We rebuild that into the
|
|
9
|
+
`/cmd <args...>` text shape Telegram produces, so behavior matches
|
|
10
|
+
one-to-one.
|
|
11
|
+
|
|
12
|
+
- **Gateway** (Phase 6c, `DISCORD_MODE=gateway`): chat messages
|
|
13
|
+
arrive as `MESSAGE_CREATE` dispatch events over WSS. Discord does
|
|
14
|
+
NOT deliver slash command interactions through the Gateway
|
|
15
|
+
(platform limitation), so the UX shifts to "@bot cmd args" in
|
|
16
|
+
guild channels OR plain `cmd args` in DMs. The adapter strips
|
|
17
|
+
the `<@bot_id>` mention prefix and normalizes the result into the
|
|
18
|
+
same `/cmd args...` shape the dispatcher expects.
|
|
19
|
+
|
|
20
|
+
Replies:
|
|
21
|
+
- Interactions path → PATCH @original webhook URL (no bot-token auth)
|
|
22
|
+
- Gateway path → POST /channels/{id}/messages (bot-token auth)
|
|
23
|
+
|
|
24
|
+
The two paths share `parse()` via type sniffing on the raw payload —
|
|
25
|
+
Interaction objects always have a `type` int field; MessageCreatePayload
|
|
26
|
+
always has `content` + `author`. Easier than splitting into two
|
|
27
|
+
adapters and saves the router/daemon from caring which path the
|
|
28
|
+
adapter chose.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from typing import TYPE_CHECKING, Any
|
|
36
|
+
|
|
37
|
+
from bot_cmder.adapters.base import PlatformAdapter
|
|
38
|
+
from bot_cmder.adapters.discord.client import DiscordClient
|
|
39
|
+
from bot_cmder.adapters.discord.schemas import Interaction, InteractionType, MessageCreatePayload
|
|
40
|
+
from bot_cmder.core.events import IncomingMessage, OutgoingResponse, Platform, PlatformUser
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Discord renders @-mentions in message content as `<@USER_ID>` (or
|
|
47
|
+
# `<@!USER_ID>` for nicknames in older clients). Strip both shapes.
|
|
48
|
+
_MENTION_RE = re.compile(r"<@!?(\d+)>")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DiscordAdapter(PlatformAdapter):
|
|
52
|
+
platform = Platform.DISCORD
|
|
53
|
+
|
|
54
|
+
def __init__(self, client: DiscordClient, bot_user_id: str | None = None) -> None:
|
|
55
|
+
"""`bot_user_id` is required for Gateway mode (so the adapter
|
|
56
|
+
knows which mention IDs to strip from message content); for
|
|
57
|
+
Interactions-only mode it can be None."""
|
|
58
|
+
self._client = client
|
|
59
|
+
self._bot_user_id = bot_user_id
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def client(self) -> DiscordClient:
|
|
63
|
+
return self._client
|
|
64
|
+
|
|
65
|
+
def parse(self, raw: Any) -> IncomingMessage | None:
|
|
66
|
+
# Type-sniff between the two payload shapes. MessageCreatePayload
|
|
67
|
+
# always has `content` + `author`; Interaction always has `type`
|
|
68
|
+
# + `application_id`. Pre-validated objects pass through.
|
|
69
|
+
if isinstance(raw, Interaction):
|
|
70
|
+
return self._parse_interaction(raw)
|
|
71
|
+
if isinstance(raw, MessageCreatePayload):
|
|
72
|
+
return self._parse_message_create(raw)
|
|
73
|
+
if isinstance(raw, dict):
|
|
74
|
+
if "application_id" in raw and "type" in raw:
|
|
75
|
+
return self._parse_interaction(Interaction.model_validate(raw))
|
|
76
|
+
if "content" in raw and "author" in raw:
|
|
77
|
+
return self._parse_message_create(MessageCreatePayload.model_validate(raw))
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def _parse_interaction(self, interaction: Interaction) -> IncomingMessage | None:
|
|
81
|
+
if interaction.type != InteractionType.APPLICATION_COMMAND:
|
|
82
|
+
# PING (type 1) is answered inline in the router. Other
|
|
83
|
+
# types (autocomplete, modal submit, message component)
|
|
84
|
+
# aren't part of Phase 4's surface.
|
|
85
|
+
return None
|
|
86
|
+
if interaction.data is None or not interaction.data.name:
|
|
87
|
+
return None
|
|
88
|
+
caller = interaction.caller()
|
|
89
|
+
if caller is None:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
# Flatten the option tree back into `/cmd args...` text so the
|
|
93
|
+
# dispatcher sees the same shape Telegram produces. Two layouts
|
|
94
|
+
# we accept (Discord rule: a slash command's options are EITHER
|
|
95
|
+
# all leaf values OR all sub-commands, never mixed):
|
|
96
|
+
#
|
|
97
|
+
# 1. Leaf options only — e.g. /service with one `args` STRING:
|
|
98
|
+
# options=[{name:"args", type:3, value:"restart api"}]
|
|
99
|
+
# → "/service restart api"
|
|
100
|
+
#
|
|
101
|
+
# 2. SUB_COMMAND wrapper (issue #18) — e.g. /otp emergency 5:
|
|
102
|
+
# options=[{name:"emergency", type:1, options:[
|
|
103
|
+
# {name:"minutes", type:4, value:5}]}]
|
|
104
|
+
# → "/otp emergency 5"
|
|
105
|
+
#
|
|
106
|
+
# SUB_COMMAND_GROUP (type=2) would add another nesting level,
|
|
107
|
+
# but we don't emit any in our schema, so a single descent
|
|
108
|
+
# suffices. If we ever do, this should grow into a recursive
|
|
109
|
+
# walk.
|
|
110
|
+
parts: list[str] = [f"/{interaction.data.name}"]
|
|
111
|
+
for opt in interaction.data.options or []:
|
|
112
|
+
if opt.type == 1: # SUB_COMMAND — descend one level
|
|
113
|
+
parts.append(opt.name)
|
|
114
|
+
for inner in opt.options or []:
|
|
115
|
+
if inner.value is None:
|
|
116
|
+
continue
|
|
117
|
+
parts.append(str(inner.value))
|
|
118
|
+
continue
|
|
119
|
+
if opt.value is None:
|
|
120
|
+
continue
|
|
121
|
+
parts.append(str(opt.value))
|
|
122
|
+
text = " ".join(parts)
|
|
123
|
+
|
|
124
|
+
return IncomingMessage(
|
|
125
|
+
platform=Platform.DISCORD,
|
|
126
|
+
user=PlatformUser(
|
|
127
|
+
platform=Platform.DISCORD,
|
|
128
|
+
raw_id=caller.id,
|
|
129
|
+
handle=caller.username,
|
|
130
|
+
display_name=caller.global_name or caller.username,
|
|
131
|
+
),
|
|
132
|
+
chat_id=interaction.channel_id or interaction.guild_id or "dm",
|
|
133
|
+
text=text,
|
|
134
|
+
message_id=interaction.id,
|
|
135
|
+
raw=interaction.model_dump(),
|
|
136
|
+
received_at=datetime.now(timezone.utc),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _parse_message_create(self, msg: MessageCreatePayload) -> IncomingMessage | None:
|
|
140
|
+
"""Decide whether a Gateway MESSAGE_CREATE counts as a command,
|
|
141
|
+
and if so, normalize it to `/cmd args...` text shape.
|
|
142
|
+
|
|
143
|
+
Three filters in order:
|
|
144
|
+
1. Skip messages from any bot account (including ourselves) —
|
|
145
|
+
prevents reply loops.
|
|
146
|
+
2. Decide if the message addresses our bot:
|
|
147
|
+
- DM (guild_id null): every message counts
|
|
148
|
+
- guild channel: only if our bot is in `mentions`
|
|
149
|
+
3. Strip the `<@bot_id>` mention prefix from content; if the
|
|
150
|
+
remaining text doesn't already start with `/`, prepend it
|
|
151
|
+
so the dispatcher recognizes it as a command.
|
|
152
|
+
|
|
153
|
+
Returns None when any filter rejects (caller logs and moves on).
|
|
154
|
+
"""
|
|
155
|
+
if msg.author.bot:
|
|
156
|
+
return None
|
|
157
|
+
# Guild messages must @-mention us to count as a command;
|
|
158
|
+
# DMs accept any content.
|
|
159
|
+
if not msg.is_dm() and (self._bot_user_id is None or not msg.mentions_user(self._bot_user_id)):
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
# Strip every <@id> / <@!id> mention from the content (not just
|
|
163
|
+
# the leading one) and collapse whitespace. This handles
|
|
164
|
+
# `@sre_bot service restart` AND `service @sre_bot restart`
|
|
165
|
+
# (the second is unusual but Discord allows mentions anywhere).
|
|
166
|
+
bare = _MENTION_RE.sub(" ", msg.content).strip()
|
|
167
|
+
bare = " ".join(bare.split()) # collapse runs of whitespace
|
|
168
|
+
if not bare:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
text = bare if bare.startswith("/") else f"/{bare}"
|
|
172
|
+
|
|
173
|
+
return IncomingMessage(
|
|
174
|
+
platform=Platform.DISCORD,
|
|
175
|
+
user=PlatformUser(
|
|
176
|
+
platform=Platform.DISCORD,
|
|
177
|
+
raw_id=msg.author.id,
|
|
178
|
+
handle=msg.author.username,
|
|
179
|
+
display_name=msg.author.global_name or msg.author.username,
|
|
180
|
+
),
|
|
181
|
+
chat_id=msg.channel_id,
|
|
182
|
+
text=text,
|
|
183
|
+
message_id=msg.id,
|
|
184
|
+
# Stash channel_id explicitly — adapter.send() uses it to
|
|
185
|
+
# route the reply via the Gateway path (POST /channels/.../messages).
|
|
186
|
+
raw={**msg.model_dump(), "_via": "gateway"},
|
|
187
|
+
received_at=datetime.now(timezone.utc),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def send(self, msg: IncomingMessage, resp: OutgoingResponse) -> None:
|
|
191
|
+
# Branch by ingestion path. Gateway-originated messages
|
|
192
|
+
# don't have a per-invocation interaction token — they reply
|
|
193
|
+
# via the regular bot-authed REST endpoint.
|
|
194
|
+
raw = msg.raw if isinstance(msg.raw, dict) else {}
|
|
195
|
+
if raw.get("_via") == "gateway":
|
|
196
|
+
await self._client.create_message(msg.chat_id, resp.text)
|
|
197
|
+
return
|
|
198
|
+
token = raw.get("token")
|
|
199
|
+
if not token:
|
|
200
|
+
# Without the per-interaction token we can't reach the
|
|
201
|
+
# @original webhook URL; nothing to do.
|
|
202
|
+
return
|
|
203
|
+
await self._client.patch_original_response(token, resp.text)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Minimal async Discord REST client.
|
|
2
|
+
|
|
3
|
+
Two-purpose:
|
|
4
|
+
|
|
5
|
+
1. Reply to a deferred interaction by PATCHing the @original
|
|
6
|
+
message via the interaction's webhook URL. These endpoints are
|
|
7
|
+
keyed by interaction_token and don't take the bot's Bearer auth
|
|
8
|
+
— they're effectively per-interaction one-shot URLs.
|
|
9
|
+
2. Register the application's slash command schema (PUT
|
|
10
|
+
/applications/{id}/commands or .../guilds/{guild_id}/commands).
|
|
11
|
+
These endpoints DO need the bot token in `Authorization: Bot`.
|
|
12
|
+
|
|
13
|
+
Discord caps message content at 2000 chars; longer replies are
|
|
14
|
+
truncated with a `[...truncated]` marker so the operator knows.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
_DISCORD_MAX_MESSAGE_CHARS = 2000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DiscordClient:
|
|
27
|
+
BASE_URL = "https://discord.com/api/v10"
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
bot_token: str,
|
|
33
|
+
application_id: str,
|
|
34
|
+
base_url: str = BASE_URL,
|
|
35
|
+
timeout_s: float = 10.0,
|
|
36
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._bot_token = bot_token
|
|
39
|
+
self._application_id = application_id
|
|
40
|
+
self._client = httpx.AsyncClient(
|
|
41
|
+
base_url=base_url,
|
|
42
|
+
timeout=timeout_s,
|
|
43
|
+
transport=transport,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async def aclose(self) -> None:
|
|
47
|
+
await self._client.aclose()
|
|
48
|
+
|
|
49
|
+
async def __aenter__(self) -> DiscordClient:
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
53
|
+
await self.aclose()
|
|
54
|
+
|
|
55
|
+
async def patch_original_response(
|
|
56
|
+
self,
|
|
57
|
+
interaction_token: str,
|
|
58
|
+
content: str,
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
"""Replace a deferred interaction's @original message body.
|
|
61
|
+
|
|
62
|
+
No bot-token auth: the interaction_token itself authorizes
|
|
63
|
+
the call. Discord's content limit is 2000 chars; we truncate
|
|
64
|
+
beyond that with a marker so reply contents that come back
|
|
65
|
+
from a chatty SSH command don't silently disappear.
|
|
66
|
+
"""
|
|
67
|
+
url = f"/webhooks/{self._application_id}/{interaction_token}/messages/@original"
|
|
68
|
+
truncated = _truncate(content)
|
|
69
|
+
resp = await self._client.patch(url, json={"content": truncated})
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
data: dict[str, Any] = resp.json()
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
async def create_message(self, channel_id: str, content: str) -> dict[str, Any]:
|
|
75
|
+
"""POST a chat message to a channel.
|
|
76
|
+
|
|
77
|
+
Used by Phase 6c Gateway-mode replies — interactions reply via
|
|
78
|
+
the per-invocation webhook URL (no auth needed), but Gateway-
|
|
79
|
+
originated messages don't have one, so we go through the
|
|
80
|
+
regular bot-authed REST endpoint. Same 2000-char truncation
|
|
81
|
+
the @original PATCH path uses.
|
|
82
|
+
"""
|
|
83
|
+
url = f"/channels/{channel_id}/messages"
|
|
84
|
+
truncated = _truncate(content)
|
|
85
|
+
resp = await self._client.post(
|
|
86
|
+
url,
|
|
87
|
+
json={"content": truncated},
|
|
88
|
+
headers={"Authorization": f"Bot {self._bot_token}"},
|
|
89
|
+
)
|
|
90
|
+
resp.raise_for_status()
|
|
91
|
+
data: dict[str, Any] = resp.json()
|
|
92
|
+
return data
|
|
93
|
+
|
|
94
|
+
async def overwrite_global_commands(self, commands: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
95
|
+
"""PUT-replace the application's global slash command set."""
|
|
96
|
+
return await self._put_commands(f"/applications/{self._application_id}/commands", commands)
|
|
97
|
+
|
|
98
|
+
async def overwrite_guild_commands(
|
|
99
|
+
self,
|
|
100
|
+
guild_id: str,
|
|
101
|
+
commands: list[dict[str, Any]],
|
|
102
|
+
) -> list[dict[str, Any]]:
|
|
103
|
+
"""PUT-replace a guild's slash command set. Updates propagate instantly."""
|
|
104
|
+
return await self._put_commands(
|
|
105
|
+
f"/applications/{self._application_id}/guilds/{guild_id}/commands",
|
|
106
|
+
commands,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def _put_commands(self, url: str, commands: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
110
|
+
resp = await self._client.put(
|
|
111
|
+
url,
|
|
112
|
+
json=commands,
|
|
113
|
+
headers={"Authorization": f"Bot {self._bot_token}"},
|
|
114
|
+
)
|
|
115
|
+
resp.raise_for_status()
|
|
116
|
+
data: list[dict[str, Any]] = resp.json()
|
|
117
|
+
return data
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _truncate(content: str) -> str:
|
|
121
|
+
if not content:
|
|
122
|
+
return "(no output)"
|
|
123
|
+
if len(content) <= _DISCORD_MAX_MESSAGE_CHARS:
|
|
124
|
+
return content
|
|
125
|
+
marker = "\n[...truncated]"
|
|
126
|
+
return content[: _DISCORD_MAX_MESSAGE_CHARS - len(marker)] + marker
|