banterbotapi 0.2.2__tar.gz → 0.2.3__tar.gz
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.
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/PKG-INFO +1 -1
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/__init__.py +14 -2
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/client.py +112 -11
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/commands.py +104 -1
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/http.py +50 -20
- banterbotapi-0.2.3/banterapi/interactions.py +161 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterbotapi.egg-info/PKG-INFO +1 -1
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterbotapi.egg-info/SOURCES.txt +1 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/pyproject.toml +1 -1
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/LICENSE +0 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/README.md +0 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/embed.py +0 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/errors.py +0 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/gateway.py +0 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/intents.py +0 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/models.py +0 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/permissions.py +0 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterbotapi.egg-info/dependency_links.txt +0 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterbotapi.egg-info/requires.txt +0 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterbotapi.egg-info/top_level.txt +0 -0
- {banterbotapi-0.2.2 → banterbotapi-0.2.3}/setup.cfg +0 -0
|
@@ -23,7 +23,7 @@ The library mirrors discord.py conventions where possible. See the Bot,
|
|
|
23
23
|
Intents, Embed, and Permissions classes for the main entry points.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
|
-
__version__ = "0.2.
|
|
26
|
+
__version__ = "0.2.3"
|
|
27
27
|
|
|
28
28
|
from .client import Bot
|
|
29
29
|
from .intents import Intents
|
|
@@ -31,7 +31,13 @@ from .embed import Embed
|
|
|
31
31
|
from .models import User, Guild, Channel, Member, Role, Message
|
|
32
32
|
from .errors import BanterError, HTTPException, Forbidden, NotFound, RateLimited, GatewayError, LoginFailure, MissingPermissions
|
|
33
33
|
from .permissions import Permissions
|
|
34
|
-
from .commands import
|
|
34
|
+
from .commands import (
|
|
35
|
+
has_permissions,
|
|
36
|
+
SlashOption,
|
|
37
|
+
OPTION_STRING, OPTION_INTEGER, OPTION_BOOLEAN,
|
|
38
|
+
OPTION_USER, OPTION_CHANNEL, OPTION_ROLE,
|
|
39
|
+
)
|
|
40
|
+
from .interactions import Interaction
|
|
35
41
|
|
|
36
42
|
__all__ = [
|
|
37
43
|
"__version__",
|
|
@@ -46,6 +52,12 @@ __all__ = [
|
|
|
46
52
|
"Message",
|
|
47
53
|
"Permissions",
|
|
48
54
|
"has_permissions",
|
|
55
|
+
# Slash command support
|
|
56
|
+
"SlashOption",
|
|
57
|
+
"OPTION_STRING", "OPTION_INTEGER", "OPTION_BOOLEAN",
|
|
58
|
+
"OPTION_USER", "OPTION_CHANNEL", "OPTION_ROLE",
|
|
59
|
+
"Interaction",
|
|
60
|
+
# Errors
|
|
49
61
|
"BanterError",
|
|
50
62
|
"HTTPException",
|
|
51
63
|
"Forbidden",
|
|
@@ -117,7 +117,14 @@ class Bot:
|
|
|
117
117
|
self._gateway = None
|
|
118
118
|
self._handlers = {}
|
|
119
119
|
self._commands = CommandRegistry()
|
|
120
|
+
# _slash_commands stores registration metadata sent to the server
|
|
121
|
+
# at sync_commands() time: [{name, description, options}, ...].
|
|
122
|
+
# _slash_handlers maps command-name → coroutine invoked when the
|
|
123
|
+
# gateway delivers an interaction_create event for that command.
|
|
124
|
+
# Separate from _commands because slash handlers take an
|
|
125
|
+
# Interaction, not a (Context, *args) like prefix handlers.
|
|
120
126
|
self._slash_commands = []
|
|
127
|
+
self._slash_handlers = {}
|
|
121
128
|
self._loop = None
|
|
122
129
|
if self.help_enabled:
|
|
123
130
|
self._register_default_help()
|
|
@@ -150,7 +157,7 @@ class Bot:
|
|
|
150
157
|
self._handlers[name] = fn
|
|
151
158
|
return fn
|
|
152
159
|
|
|
153
|
-
def command(self, name=None, aliases=None, help=None, slash=False, description=""):
|
|
160
|
+
def command(self, name=None, aliases=None, help=None, slash=False, description="", options=None):
|
|
154
161
|
"""Register a prefix command (optionally also as a slash command).
|
|
155
162
|
|
|
156
163
|
The decorated function is called as ``await fn(ctx, *args)`` —
|
|
@@ -172,34 +179,104 @@ class Bot:
|
|
|
172
179
|
``/`` autocomplete dropdown.
|
|
173
180
|
description: Slash-command description. Ignored when
|
|
174
181
|
``slash=False``. Falls back to ``help`` or the name.
|
|
182
|
+
options: Iterable of :class:`~banterapi.SlashOption` to
|
|
183
|
+
expose as typed slash-command arguments. Only
|
|
184
|
+
applies when ``slash=True``. When a user invokes the
|
|
185
|
+
slash command, the server parses the typed options
|
|
186
|
+
and delivers them to the slash handler as
|
|
187
|
+
``interaction.options``. Prefix-invocation is
|
|
188
|
+
unaffected — prefix args continue to parse from the
|
|
189
|
+
message text via function-signature introspection.
|
|
175
190
|
|
|
176
191
|
Usage::
|
|
177
192
|
|
|
178
193
|
@bot.command(aliases=["p"], slash=True, description="Health check")
|
|
179
194
|
async def ping(ctx):
|
|
180
195
|
await ctx.reply("pong")
|
|
196
|
+
|
|
197
|
+
With typed slash options::
|
|
198
|
+
|
|
199
|
+
from banterapi import SlashOption, OPTION_INTEGER
|
|
200
|
+
|
|
201
|
+
@bot.command(
|
|
202
|
+
slash=True, description="Roll a die",
|
|
203
|
+
options=[SlashOption("sides", type=OPTION_INTEGER, required=False)],
|
|
204
|
+
)
|
|
205
|
+
async def roll(ctx, sides: int = 6):
|
|
206
|
+
await ctx.reply(f"🎲 {sides}")
|
|
181
207
|
"""
|
|
182
208
|
def decorator(fn):
|
|
183
209
|
cmd = Command(fn, name=name, aliases=aliases, help=help)
|
|
184
210
|
self._commands.add(cmd)
|
|
185
211
|
if slash:
|
|
186
|
-
|
|
212
|
+
entry = {
|
|
187
213
|
"name": cmd.name,
|
|
188
214
|
"description": description or cmd.help or cmd.name,
|
|
189
|
-
}
|
|
215
|
+
}
|
|
216
|
+
if options:
|
|
217
|
+
# Defer import to avoid a circular: commands.py is
|
|
218
|
+
# loaded by client.py, and SlashOption lives there.
|
|
219
|
+
from .commands import SlashOption as _SO
|
|
220
|
+
entry["options"] = [
|
|
221
|
+
(o.to_dict() if isinstance(o, _SO) else dict(o))
|
|
222
|
+
for o in options
|
|
223
|
+
]
|
|
224
|
+
self._slash_commands.append(entry)
|
|
190
225
|
return fn
|
|
191
226
|
return decorator
|
|
192
227
|
|
|
193
|
-
def slash_command(self, name=None, description=""):
|
|
194
|
-
"""
|
|
228
|
+
def slash_command(self, name=None, description="", options=None):
|
|
229
|
+
"""Register a standalone slash command (no prefix twin).
|
|
230
|
+
|
|
231
|
+
The decorated function takes a single argument — an
|
|
232
|
+
:class:`~banterapi.interactions.Interaction` — and responds
|
|
233
|
+
via ``await interaction.respond(...)``. This differs from
|
|
234
|
+
:meth:`command` (prefix commands) which takes a
|
|
235
|
+
:class:`~banterapi.commands.Context` and parses args from
|
|
236
|
+
message text.
|
|
237
|
+
|
|
238
|
+
Use this when the command has structured typed args and makes
|
|
239
|
+
no sense as a plain-text prefix command, or when you want the
|
|
240
|
+
command only accessible through the UI autocomplete.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
name: Slash command name. Defaults to the function name.
|
|
244
|
+
description: Shown in the autocomplete dropdown.
|
|
245
|
+
options: Iterable of :class:`~banterapi.SlashOption`.
|
|
246
|
+
|
|
247
|
+
Usage::
|
|
248
|
+
|
|
249
|
+
from banterapi import SlashOption, OPTION_STRING, OPTION_INTEGER
|
|
195
250
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
251
|
+
@bot.slash_command(
|
|
252
|
+
name="greet",
|
|
253
|
+
description="Greet a user with a custom message",
|
|
254
|
+
options=[
|
|
255
|
+
SlashOption("name", type=OPTION_STRING),
|
|
256
|
+
SlashOption("times", type=OPTION_INTEGER, required=False),
|
|
257
|
+
],
|
|
258
|
+
)
|
|
259
|
+
async def greet(interaction):
|
|
260
|
+
n = interaction.options["name"]
|
|
261
|
+
k = interaction.options.get("times", 1)
|
|
262
|
+
await interaction.respond(f"Hello {n}! " * k)
|
|
201
263
|
"""
|
|
202
|
-
|
|
264
|
+
def decorator(fn):
|
|
265
|
+
cmd_name = name or fn.__name__
|
|
266
|
+
entry = {
|
|
267
|
+
"name": cmd_name,
|
|
268
|
+
"description": description or cmd_name,
|
|
269
|
+
}
|
|
270
|
+
if options:
|
|
271
|
+
from .commands import SlashOption as _SO
|
|
272
|
+
entry["options"] = [
|
|
273
|
+
(o.to_dict() if isinstance(o, _SO) else dict(o))
|
|
274
|
+
for o in options
|
|
275
|
+
]
|
|
276
|
+
self._slash_commands.append(entry)
|
|
277
|
+
self._slash_handlers[cmd_name] = fn
|
|
278
|
+
return fn
|
|
279
|
+
return decorator
|
|
203
280
|
|
|
204
281
|
def _register_default_help(self):
|
|
205
282
|
from .embed import Embed
|
|
@@ -335,6 +412,30 @@ class Bot:
|
|
|
335
412
|
await self._fire("on_error", payload)
|
|
336
413
|
return
|
|
337
414
|
|
|
415
|
+
if event_type == "interaction_create":
|
|
416
|
+
# Route slash-command invocations to their registered
|
|
417
|
+
# handlers. Falls back to a no-op (plus on_raw_event) if
|
|
418
|
+
# the command name isn't known — that shouldn't normally
|
|
419
|
+
# happen because the server filters by command ownership,
|
|
420
|
+
# but we don't want to crash the dispatch loop on drift.
|
|
421
|
+
from .interactions import Interaction
|
|
422
|
+
interaction = Interaction(payload, client=self)
|
|
423
|
+
handler = self._slash_handlers.get(interaction.command_name)
|
|
424
|
+
if handler is not None:
|
|
425
|
+
try:
|
|
426
|
+
result = handler(interaction)
|
|
427
|
+
if inspect.isawaitable(result):
|
|
428
|
+
await result
|
|
429
|
+
except Exception:
|
|
430
|
+
log.exception("slash handler %r raised", interaction.command_name)
|
|
431
|
+
else:
|
|
432
|
+
log.warning(
|
|
433
|
+
"received interaction for unknown slash command %r",
|
|
434
|
+
interaction.command_name,
|
|
435
|
+
)
|
|
436
|
+
await self._fire("on_interaction", interaction)
|
|
437
|
+
return
|
|
438
|
+
|
|
338
439
|
await self._fire("on_raw_event", event_type, payload)
|
|
339
440
|
|
|
340
441
|
async def _fire(self, name, *args):
|
|
@@ -407,4 +407,107 @@ class CommandRegistry:
|
|
|
407
407
|
continue
|
|
408
408
|
seen.add(cmd.name)
|
|
409
409
|
out.append(cmd)
|
|
410
|
-
return out
|
|
410
|
+
return out
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# -----------------------------------------------------------------
|
|
414
|
+
# Slash command option metadata
|
|
415
|
+
# -----------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
# Option type constants. Match the wire strings the server stores in
|
|
418
|
+
# the `options` JSON column for each registered command. Keep these
|
|
419
|
+
# as strings (not enums) so the JSON payload stays human-readable.
|
|
420
|
+
OPTION_STRING = "string"
|
|
421
|
+
OPTION_INTEGER = "integer"
|
|
422
|
+
OPTION_BOOLEAN = "boolean"
|
|
423
|
+
OPTION_USER = "user"
|
|
424
|
+
OPTION_CHANNEL = "channel"
|
|
425
|
+
OPTION_ROLE = "role"
|
|
426
|
+
|
|
427
|
+
_VALID_OPTION_TYPES = {
|
|
428
|
+
OPTION_STRING, OPTION_INTEGER, OPTION_BOOLEAN,
|
|
429
|
+
OPTION_USER, OPTION_CHANNEL, OPTION_ROLE,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class SlashOption:
|
|
434
|
+
"""One named argument to a slash command.
|
|
435
|
+
|
|
436
|
+
Registered as part of a slash command's options list. The server
|
|
437
|
+
stores the metadata so the client can render appropriate input
|
|
438
|
+
widgets (text box, number input, user picker, channel picker).
|
|
439
|
+
On invocation, option values are parsed server-side into the
|
|
440
|
+
declared type and delivered to the bot as typed fields on the
|
|
441
|
+
:class:`Interaction` context.
|
|
442
|
+
|
|
443
|
+
Attributes:
|
|
444
|
+
name: Option identifier (a-z, 0-9, underscore). Used both in
|
|
445
|
+
the UI and as the key in ``interaction.options[name]``.
|
|
446
|
+
type: One of the ``OPTION_*`` constants — string, integer,
|
|
447
|
+
boolean, user, channel, role.
|
|
448
|
+
description: Human-readable prompt shown under the option.
|
|
449
|
+
required: If True, the invocation UI blocks submission until
|
|
450
|
+
the value is set. Defaults to True — explicit optionals
|
|
451
|
+
are clearer than defaults-by-omission.
|
|
452
|
+
choices: Optional list of fixed choices. Each is either a
|
|
453
|
+
plain scalar (``["low", "medium", "high"]``) or a dict
|
|
454
|
+
``{"name": "Medium", "value": "medium"}``. Frontend
|
|
455
|
+
renders as a dropdown when present.
|
|
456
|
+
|
|
457
|
+
Example::
|
|
458
|
+
|
|
459
|
+
@bot.slash_command(
|
|
460
|
+
name="kick",
|
|
461
|
+
description="Kick a member",
|
|
462
|
+
options=[
|
|
463
|
+
SlashOption("user", type=OPTION_USER, description="Who to kick"),
|
|
464
|
+
SlashOption("reason", type=OPTION_STRING, description="Why", required=False),
|
|
465
|
+
],
|
|
466
|
+
)
|
|
467
|
+
async def kick(interaction):
|
|
468
|
+
target = interaction.options["user"] # user ID (str)
|
|
469
|
+
reason = interaction.options.get("reason", "")
|
|
470
|
+
...
|
|
471
|
+
"""
|
|
472
|
+
|
|
473
|
+
__slots__ = ("name", "type", "description", "required", "choices")
|
|
474
|
+
|
|
475
|
+
def __init__(self, name, *, type=OPTION_STRING, description="", required=True, choices=None):
|
|
476
|
+
if type not in _VALID_OPTION_TYPES:
|
|
477
|
+
raise ValueError(
|
|
478
|
+
f"SlashOption {name!r}: unknown type {type!r}. "
|
|
479
|
+
f"Use one of: {', '.join(sorted(_VALID_OPTION_TYPES))}"
|
|
480
|
+
)
|
|
481
|
+
self.name = name
|
|
482
|
+
self.type = type
|
|
483
|
+
self.description = description
|
|
484
|
+
self.required = bool(required)
|
|
485
|
+
self.choices = list(choices) if choices else None
|
|
486
|
+
|
|
487
|
+
def to_dict(self):
|
|
488
|
+
"""Serialize to the JSON shape the server stores and the
|
|
489
|
+
frontend consumes. Omits ``choices`` when unset to keep the
|
|
490
|
+
payload compact."""
|
|
491
|
+
d = {
|
|
492
|
+
"name": self.name,
|
|
493
|
+
"type": self.type,
|
|
494
|
+
"description": self.description,
|
|
495
|
+
"required": self.required,
|
|
496
|
+
}
|
|
497
|
+
if self.choices is not None:
|
|
498
|
+
# Allow bare scalars or {name, value} dicts — normalize to
|
|
499
|
+
# dicts so the frontend doesn't have to branch.
|
|
500
|
+
normalized = []
|
|
501
|
+
for c in self.choices:
|
|
502
|
+
if isinstance(c, dict) and "value" in c:
|
|
503
|
+
normalized.append({"name": str(c.get("name", c["value"])), "value": c["value"]})
|
|
504
|
+
else:
|
|
505
|
+
normalized.append({"name": str(c), "value": c})
|
|
506
|
+
d["choices"] = normalized
|
|
507
|
+
return d
|
|
508
|
+
|
|
509
|
+
def __repr__(self):
|
|
510
|
+
req = "required" if self.required else "optional"
|
|
511
|
+
return f"SlashOption({self.name!r}, type={self.type!r}, {req})"
|
|
512
|
+
|
|
513
|
+
|
|
@@ -41,6 +41,24 @@ _MAX_ATTEMPTS = 2
|
|
|
41
41
|
_DEFAULT_USER_AGENT = f"BanterPy/{__version__} (+https://banterchat.org)"
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def _parse_retry_after(raw):
|
|
45
|
+
"""Parse an X-RateLimit-Reset-After header value into seconds.
|
|
46
|
+
|
|
47
|
+
Returns 1.0 (a conservative default) for missing, empty, or
|
|
48
|
+
non-numeric headers. Always returns a positive finite float so
|
|
49
|
+
callers can safely pass it to :func:`asyncio.sleep`.
|
|
50
|
+
"""
|
|
51
|
+
if not raw:
|
|
52
|
+
return 1.0
|
|
53
|
+
try:
|
|
54
|
+
value = float(raw)
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
return 1.0
|
|
57
|
+
if value != value or value <= 0: # NaN or non-positive
|
|
58
|
+
return 1.0
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
44
62
|
class Route:
|
|
45
63
|
"""Describes one REST endpoint: method, path template, and params.
|
|
46
64
|
|
|
@@ -116,25 +134,7 @@ class HTTPClient:
|
|
|
116
134
|
if self._session and not self._session.closed:
|
|
117
135
|
await self._session.close()
|
|
118
136
|
|
|
119
|
-
|
|
120
|
-
def _parse_retry_after(raw):
|
|
121
|
-
"""Parse an X-RateLimit-Reset-After header value into seconds.
|
|
122
|
-
|
|
123
|
-
Returns 1.0 (a conservative default) for missing, empty, or
|
|
124
|
-
non-numeric headers. Always returns a positive finite float so
|
|
125
|
-
callers can safely pass it to :func:`asyncio.sleep`.
|
|
126
|
-
"""
|
|
127
|
-
if not raw:
|
|
128
|
-
return 1.0
|
|
129
|
-
try:
|
|
130
|
-
value = float(raw)
|
|
131
|
-
except (TypeError, ValueError):
|
|
132
|
-
return 1.0
|
|
133
|
-
if value != value or value <= 0: # NaN or non-positive
|
|
134
|
-
return 1.0
|
|
135
|
-
return value
|
|
136
|
-
|
|
137
|
-
async def request(self, route, json_body=None, params=None):
|
|
137
|
+
async def request(self, route, json_body=None, params=None, extra_headers=None):
|
|
138
138
|
"""Issue a REST call described by ``route`` and translate the response.
|
|
139
139
|
|
|
140
140
|
Args:
|
|
@@ -143,6 +143,10 @@ def _parse_retry_after(raw):
|
|
|
143
143
|
json_body: Object to serialise as the JSON request body,
|
|
144
144
|
or ``None`` for no body.
|
|
145
145
|
params: Dict of query-string parameters, or ``None``.
|
|
146
|
+
extra_headers: Per-request headers merged on top of the
|
|
147
|
+
session defaults (the session already sets Authorization
|
|
148
|
+
and User-Agent). Used for one-off headers like
|
|
149
|
+
X-Interaction-Token that don't belong on every request.
|
|
146
150
|
|
|
147
151
|
Returns:
|
|
148
152
|
The parsed JSON response body (dict or list), or ``None``
|
|
@@ -159,7 +163,7 @@ def _parse_retry_after(raw):
|
|
|
159
163
|
url = self.base_url + route.url
|
|
160
164
|
method = route.method
|
|
161
165
|
for attempt in range(_MAX_ATTEMPTS):
|
|
162
|
-
async with self._session.request(method, url, json=json_body, params=params) as resp:
|
|
166
|
+
async with self._session.request(method, url, json=json_body, params=params, headers=extra_headers) as resp:
|
|
163
167
|
if resp.status == HTTPStatus.NO_CONTENT:
|
|
164
168
|
return None
|
|
165
169
|
text = await resp.text()
|
|
@@ -509,6 +513,32 @@ def _parse_retry_after(raw):
|
|
|
509
513
|
json_body={"commands": commands},
|
|
510
514
|
)
|
|
511
515
|
|
|
516
|
+
async def respond_interaction(self, interaction_id, token, body):
|
|
517
|
+
"""POST /interactions/{id}/respond — reply to a slash-command invocation.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
interaction_id: The server-assigned ID from the incoming
|
|
521
|
+
``interaction_create`` event.
|
|
522
|
+
token: The interaction token from the same event; sent as
|
|
523
|
+
``X-Interaction-Token`` so the server can validate the
|
|
524
|
+
caller actually owns the interaction.
|
|
525
|
+
body: One of:
|
|
526
|
+
- ``{"kind": "reply", "content": ..., "embed": ..., "ephemeral": bool}``
|
|
527
|
+
- ``{"kind": "defer", "ephemeral": bool}``
|
|
528
|
+
- ``{"kind": "followup", "content": ..., "embed": ..., "ephemeral": bool}``
|
|
529
|
+
|
|
530
|
+
Called indirectly via :meth:`Interaction.respond` / ``defer`` /
|
|
531
|
+
``followup`` — users shouldn't touch this directly.
|
|
532
|
+
"""
|
|
533
|
+
# Token rides in a header so URL routing doesn't care about it
|
|
534
|
+
# and so logging / rate-limit keying sees only the interaction ID.
|
|
535
|
+
extra_headers = {"X-Interaction-Token": token}
|
|
536
|
+
return await self.request(
|
|
537
|
+
Route("POST", "/interactions/{interaction_id}/respond", interaction_id=interaction_id),
|
|
538
|
+
json_body=body,
|
|
539
|
+
extra_headers=extra_headers,
|
|
540
|
+
)
|
|
541
|
+
|
|
512
542
|
async def list_commands(self):
|
|
513
543
|
"""GET /applications/@me/commands — current slash cmd set."""
|
|
514
544
|
return await self.request(Route("GET", "/applications/@me/commands"))
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Slash-command interaction context.
|
|
2
|
+
|
|
3
|
+
When a user invokes a slash command, the server emits an
|
|
4
|
+
``interaction_create`` gateway event to the owning bot. The SDK wraps
|
|
5
|
+
the payload in an :class:`Interaction` and dispatches it to the
|
|
6
|
+
registered ``@bot.slash_command`` handler.
|
|
7
|
+
|
|
8
|
+
Unlike prefix commands (which deliver a :class:`~banterapi.Message`
|
|
9
|
+
via ``on_message``), slash commands deliver an :class:`Interaction`
|
|
10
|
+
with pre-parsed, typed options and a response-correlation token.
|
|
11
|
+
|
|
12
|
+
Handler usage::
|
|
13
|
+
|
|
14
|
+
@bot.slash_command(
|
|
15
|
+
name="roll",
|
|
16
|
+
description="Roll an N-sided die",
|
|
17
|
+
options=[SlashOption("sides", type=OPTION_INTEGER, required=False)],
|
|
18
|
+
)
|
|
19
|
+
async def roll(interaction):
|
|
20
|
+
sides = interaction.options.get("sides", 6)
|
|
21
|
+
await interaction.respond(f"🎲 {sides}-sided die")
|
|
22
|
+
|
|
23
|
+
Responding to an interaction:
|
|
24
|
+
|
|
25
|
+
* :meth:`Interaction.respond` — send the visible reply. The invocation
|
|
26
|
+
stays on screen as a header; this message lands below.
|
|
27
|
+
* :meth:`Interaction.defer` — acknowledge that a response is coming
|
|
28
|
+
"soon" (within the interaction timeout, typically 15 minutes). Use
|
|
29
|
+
before long-running work so the UI can show a thinking state.
|
|
30
|
+
* :meth:`Interaction.followup` — send additional messages after the
|
|
31
|
+
initial response. Can be called multiple times.
|
|
32
|
+
|
|
33
|
+
The ``ephemeral`` flag makes a response visible only to the invoker.
|
|
34
|
+
Follows Discord's semantics: ephemeral messages are dropped by
|
|
35
|
+
clients that aren't the invoker, even though they transit the same
|
|
36
|
+
gateway for everyone else.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Interaction:
|
|
41
|
+
"""One slash-command invocation delivered to the bot.
|
|
42
|
+
|
|
43
|
+
Constructed by :class:`~banterapi.Bot._dispatch` when an
|
|
44
|
+
``interaction_create`` event arrives. Do not instantiate manually.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
id: Server-assigned interaction ID. Used to correlate the
|
|
48
|
+
bot's response back to this invocation.
|
|
49
|
+
token: Short-lived bearer token the server issued for this
|
|
50
|
+
interaction. Included on every follow-up call.
|
|
51
|
+
command_name: The slash command's registered name (without
|
|
52
|
+
the leading ``/``).
|
|
53
|
+
options: Dict of ``{option_name: parsed_value}``. Values are
|
|
54
|
+
typed per the option's declared type:
|
|
55
|
+
- string → str
|
|
56
|
+
- integer → int
|
|
57
|
+
- boolean → bool
|
|
58
|
+
- user, channel, role → the target ID as str
|
|
59
|
+
guild_id: Guild the interaction was invoked in, or ``""`` for
|
|
60
|
+
a DM.
|
|
61
|
+
channel_id: Channel ID it was invoked in.
|
|
62
|
+
user_id: Invoking user's ID.
|
|
63
|
+
_client: The :class:`Bot` that owns this interaction, kept for
|
|
64
|
+
routing responses. Never serialized.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
__slots__ = (
|
|
68
|
+
"id", "token", "command_name", "options",
|
|
69
|
+
"guild_id", "channel_id", "user_id", "_client", "_responded",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def __init__(self, payload, client=None):
|
|
73
|
+
self.id = payload.get("id", "")
|
|
74
|
+
self.token = payload.get("token", "")
|
|
75
|
+
self.command_name = payload.get("command_name", "")
|
|
76
|
+
self.options = payload.get("options", {}) or {}
|
|
77
|
+
self.guild_id = payload.get("guild_id", "")
|
|
78
|
+
self.channel_id = payload.get("channel_id", "")
|
|
79
|
+
self.user_id = payload.get("user_id", "")
|
|
80
|
+
self._client = client
|
|
81
|
+
# Track whether we've sent the first response. Subsequent calls
|
|
82
|
+
# must go through followup() since the server distinguishes
|
|
83
|
+
# "initial response" from "follow-up" on the wire.
|
|
84
|
+
self._responded = False
|
|
85
|
+
|
|
86
|
+
async def respond(self, content="", *, embed=None, ephemeral=False):
|
|
87
|
+
"""Send the visible reply to the invocation.
|
|
88
|
+
|
|
89
|
+
May only be called once per interaction. After this,
|
|
90
|
+
additional messages must go through :meth:`followup`.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
content: Message text.
|
|
94
|
+
embed: Optional :class:`~banterapi.Embed`.
|
|
95
|
+
ephemeral: If True, only the invoking user sees this
|
|
96
|
+
message. Defaults to False (public).
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
RuntimeError: If called more than once on the same
|
|
100
|
+
interaction — use :meth:`followup` for subsequent
|
|
101
|
+
messages.
|
|
102
|
+
"""
|
|
103
|
+
if self._responded:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
"respond() was already called on this interaction. "
|
|
106
|
+
"Use interaction.followup(...) for additional messages."
|
|
107
|
+
)
|
|
108
|
+
if self._client is None:
|
|
109
|
+
raise RuntimeError("Interaction has no attached client.")
|
|
110
|
+
body = {
|
|
111
|
+
"kind": "reply",
|
|
112
|
+
"content": content,
|
|
113
|
+
"ephemeral": bool(ephemeral),
|
|
114
|
+
}
|
|
115
|
+
if embed is not None:
|
|
116
|
+
body["embed"] = embed.to_dict() if hasattr(embed, "to_dict") else embed
|
|
117
|
+
await self._client._http.respond_interaction(self.id, self.token, body)
|
|
118
|
+
self._responded = True
|
|
119
|
+
|
|
120
|
+
async def defer(self, *, ephemeral=False):
|
|
121
|
+
"""Acknowledge the interaction without replying yet.
|
|
122
|
+
|
|
123
|
+
Shows a thinking indicator in the client until :meth:`respond`
|
|
124
|
+
or :meth:`followup` lands. Use for handlers that may exceed
|
|
125
|
+
the client's immediate-response timeout (~3s). The ephemeral
|
|
126
|
+
flag here determines whether the eventual response will be
|
|
127
|
+
ephemeral too.
|
|
128
|
+
"""
|
|
129
|
+
if self._responded:
|
|
130
|
+
raise RuntimeError("interaction already responded to")
|
|
131
|
+
if self._client is None:
|
|
132
|
+
raise RuntimeError("Interaction has no attached client.")
|
|
133
|
+
await self._client._http.respond_interaction(self.id, self.token, {
|
|
134
|
+
"kind": "defer",
|
|
135
|
+
"ephemeral": bool(ephemeral),
|
|
136
|
+
})
|
|
137
|
+
self._responded = True
|
|
138
|
+
|
|
139
|
+
async def followup(self, content="", *, embed=None, ephemeral=False):
|
|
140
|
+
"""Send an additional message after the initial response.
|
|
141
|
+
|
|
142
|
+
Callable repeatedly. Unlike :meth:`respond` there's no
|
|
143
|
+
once-only limit — use for streaming progress updates, paginated
|
|
144
|
+
results, or multi-part replies.
|
|
145
|
+
"""
|
|
146
|
+
if self._client is None:
|
|
147
|
+
raise RuntimeError("Interaction has no attached client.")
|
|
148
|
+
body = {
|
|
149
|
+
"kind": "followup",
|
|
150
|
+
"content": content,
|
|
151
|
+
"ephemeral": bool(ephemeral),
|
|
152
|
+
}
|
|
153
|
+
if embed is not None:
|
|
154
|
+
body["embed"] = embed.to_dict() if hasattr(embed, "to_dict") else embed
|
|
155
|
+
await self._client._http.respond_interaction(self.id, self.token, body)
|
|
156
|
+
|
|
157
|
+
def __repr__(self):
|
|
158
|
+
return (
|
|
159
|
+
f"Interaction(id={self.id!r}, command={self.command_name!r}, "
|
|
160
|
+
f"user={self.user_id!r}, channel={self.channel_id!r})"
|
|
161
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|