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.
Files changed (21) hide show
  1. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/PKG-INFO +1 -1
  2. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/__init__.py +14 -2
  3. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/client.py +112 -11
  4. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/commands.py +104 -1
  5. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/http.py +50 -20
  6. banterbotapi-0.2.3/banterapi/interactions.py +161 -0
  7. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterbotapi.egg-info/PKG-INFO +1 -1
  8. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterbotapi.egg-info/SOURCES.txt +1 -0
  9. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/pyproject.toml +1 -1
  10. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/LICENSE +0 -0
  11. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/README.md +0 -0
  12. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/embed.py +0 -0
  13. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/errors.py +0 -0
  14. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/gateway.py +0 -0
  15. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/intents.py +0 -0
  16. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/models.py +0 -0
  17. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterapi/permissions.py +0 -0
  18. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterbotapi.egg-info/dependency_links.txt +0 -0
  19. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterbotapi.egg-info/requires.txt +0 -0
  20. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/banterbotapi.egg-info/top_level.txt +0 -0
  21. {banterbotapi-0.2.2 → banterbotapi-0.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: banterbotapi
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Python SDK for building bots on Banter (banterchat.org) — discord.py-style.
5
5
  Author-email: Banter <contact@banterchat.org>
6
6
  License-Expression: MIT
@@ -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.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 has_permissions
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
- self._slash_commands.append({
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
- """Shortcut for :meth:`command` with ``slash=True``.
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
- Use this when you want a command to exist *only* as a slash
197
- command (no prefix invocation). The underlying registration is
198
- identical; the command is still callable by its registered
199
- name if a user types the prefix, but it won't appear in the
200
- prefix-command listing by convention.
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
- return self.command(name=name, help=description, slash=True, description=description)
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
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: banterbotapi
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Python SDK for building bots on Banter (banterchat.org) — discord.py-style.
5
5
  Author-email: Banter <contact@banterchat.org>
6
6
  License-Expression: MIT
@@ -9,6 +9,7 @@ banterapi/errors.py
9
9
  banterapi/gateway.py
10
10
  banterapi/http.py
11
11
  banterapi/intents.py
12
+ banterapi/interactions.py
12
13
  banterapi/models.py
13
14
  banterapi/permissions.py
14
15
  banterbotapi.egg-info/PKG-INFO
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "banterbotapi"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Python SDK for building bots on Banter (banterchat.org) — discord.py-style."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes