fastapi-interactions 0.1.0__tar.gz → 0.1.1__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 (19) hide show
  1. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/PKG-INFO +1 -1
  2. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/fastapi_interactions/__init__.py +2 -0
  3. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/fastapi_interactions/bot.py +42 -12
  4. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/fastapi_interactions/commands.py +50 -8
  5. fastapi_interactions-0.1.1/fastapi_interactions/context.py +83 -0
  6. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/fastapi_interactions/middleware.py +25 -0
  7. fastapi_interactions-0.1.1/fastapi_interactions/models.py +286 -0
  8. fastapi_interactions-0.1.1/fastapi_interactions/responses.py +104 -0
  9. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/PKG-INFO +1 -1
  10. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/pyproject.toml +2 -2
  11. fastapi_interactions-0.1.0/fastapi_interactions/context.py +0 -43
  12. fastapi_interactions-0.1.0/fastapi_interactions/models.py +0 -166
  13. fastapi_interactions-0.1.0/fastapi_interactions/responses.py +0 -61
  14. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/README.md +0 -0
  15. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/SOURCES.txt +0 -0
  16. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/dependency_links.txt +0 -0
  17. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/requires.txt +0 -0
  18. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/top_level.txt +0 -0
  19. {fastapi_interactions-0.1.0 → fastapi_interactions-0.1.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-interactions
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: A lightweight framework for discord interactions over http built on FastAPI
5
5
  Author-email: Haider Ali <haideralidevnull@gmail.com>
6
6
  Requires-Python: >=3.13
@@ -1,3 +1,5 @@
1
+ """A lightweight Discord HTTP interactions framework built on FastAPI."""
2
+
1
3
  from .bot import Bot
2
4
  from .commands import CommandRouter, Option
3
5
 
@@ -1,3 +1,10 @@
1
+ """The bot application: routing, command dispatch, and Discord API syncing.
2
+
3
+ A :class:`Bot` wraps a FastAPI application, verifies Discord webhook signatures,
4
+ routes interaction payloads to registered commands, and registers commands with
5
+ Discord's API.
6
+ """
7
+
1
8
  import importlib
2
9
  import inspect
3
10
  import json
@@ -10,6 +17,7 @@ from fastapi import FastAPI, Request
10
17
  from fastapi.responses import JSONResponse
11
18
  from loguru import logger
12
19
  from pydantic import ValidationError
20
+ from starlette.background import BackgroundTask
13
21
 
14
22
  from .commands import Command, CommandRouter
15
23
  from .context import Context
@@ -20,7 +28,13 @@ from .models import (
20
28
  InteractionType,
21
29
  Snowflake,
22
30
  )
23
- from .responses import InteractionResponse, MessageResponse, PongResponse
31
+ from .responses import (
32
+ DeferResponse,
33
+ InteractionResponse,
34
+ MessageResponse,
35
+ PongResponse,
36
+ _JSONResponse,
37
+ )
24
38
 
25
39
 
26
40
  async def call_with_options(callback: callable, ctx: Context) -> Any:
@@ -62,6 +76,14 @@ async def call_with_options(callback: callable, ctx: Context) -> Any:
62
76
 
63
77
 
64
78
  class Bot:
79
+ """A Discord HTTP interactions bot built on FastAPI.
80
+
81
+ The bot exposes a single interactions endpoint (``/interactions`` by
82
+ default) and keeps commands in an internal registry. Use
83
+ :meth:`attach_router` or :meth:`load_extension` to register commands, then
84
+ sync them with Discord using :meth:`sync_commands`.
85
+ """
86
+
65
87
  def __init__(
66
88
  self,
67
89
  app_id: int,
@@ -96,7 +118,7 @@ class Bot:
96
118
  payload = json.loads(request.state.raw_body)
97
119
 
98
120
  if payload["type"] == InteractionType.PING:
99
- return await PongResponse()
121
+ return _JSONResponse(PongResponse())
100
122
 
101
123
  if payload["type"] == InteractionType.APPLICATION_COMMAND:
102
124
  """Construct Context"""
@@ -105,11 +127,9 @@ class Bot:
105
127
  application_command = ApplicationCommandData.model_validate(
106
128
  interaction.data
107
129
  )
108
- except (ValidationError, Exception):
109
- # print(e.errors())
110
- return await MessageResponse("Unexpected error", ephemeral=True)
130
+ except (ValidationError, Exception) as e:
131
+ return _JSONResponse(MessageResponse("Unexpected error", ephemeral=True))
111
132
 
112
- raise Exception("Unexpected error occurred")
113
133
  context = Context(
114
134
  interaction=interaction, options=application_command, http=self.http
115
135
  )
@@ -118,13 +138,13 @@ class Bot:
118
138
  command_name=application_command.name, ctx=context
119
139
  )
120
140
 
121
- return await MessageResponse("Unsupported interaction received", ephemeral=True)
141
+ return _JSONResponse(MessageResponse("Unsupported interaction received", ephemeral=True))
122
142
 
123
143
  async def add_error_handler(self, exc: Exception, func: callable):
124
144
  pass
125
145
 
126
146
  async def _handle_exception(self, request: Request, exc: Exception):
127
- return JSONResponse(await MessageResponse(str(exc), ephemeral=True))
147
+ return _JSONResponse(MessageResponse(str(exc), ephemeral=True))
128
148
 
129
149
  def _register_routes(self) -> None:
130
150
  """Set up middleware and the interactions endpoint.
@@ -289,14 +309,24 @@ class Bot:
289
309
  """
290
310
  command = self.commands.get(command_name)
291
311
  if command is None:
292
- return await MessageResponse("Unknown command", ephemeral=True)
312
+ return _JSONResponse(MessageResponse("Unknown command", ephemeral=True))
293
313
 
294
314
  result = await call_with_options(command.callback, ctx)
295
315
 
296
- if not isinstance(result, InteractionResponse):
297
- result = MessageResponse(str(result))
316
+ background = None
317
+ if isinstance(result, DeferResponse) and result.finish:
318
+ background = BackgroundTask(self._run_finish, result.finish, ctx=ctx)
319
+ if isinstance(result, str):
320
+ result = MessageResponse(result)
321
+
322
+ return _JSONResponse(result, background=background)
298
323
 
299
- return await result
324
+ async def _run_finish(self, finish, ctx: Context) -> None:
325
+ try:
326
+ await finish()
327
+ except Exception as e:
328
+ logger.exception('Defer handler failed')
329
+ # await self._report_failure(ctx)
300
330
 
301
331
  def delete_all_commands(self, guild_id: Snowflake = None) -> None:
302
332
  """
@@ -1,4 +1,12 @@
1
+ """Command registration, routing, and option decorators.
2
+
3
+ Routers group commands and carry settings such as guild scope, integration
4
+ types, and interaction contexts. The :class:`Option` decorators attach option
5
+ metadata that is later bound to command callback parameters.
6
+ """
7
+
1
8
  import sys
9
+ from collections.abc import Callable
2
10
  from dataclasses import dataclass, field
3
11
 
4
12
  from .models import (
@@ -13,6 +21,8 @@ OptionType = ApplicationCommandOptionType
13
21
 
14
22
  @dataclass
15
23
  class CommandOption:
24
+ """A single option declared on a command."""
25
+
16
26
  name: str
17
27
  description: str
18
28
  type: OptionType = OptionType.STRING
@@ -29,6 +39,12 @@ class CommandOption:
29
39
 
30
40
  @dataclass(slots=True)
31
41
  class CommandMeta:
42
+ """Registration metadata sent to Discord for a command.
43
+
44
+ ``integration_types`` and ``contexts`` control where the command can be
45
+ installed and invoked. They are only valid for globally scoped commands.
46
+ """
47
+
32
48
  name: str
33
49
  description: str
34
50
  options: list[CommandOption] = field(default_factory=list)
@@ -52,12 +68,31 @@ class CommandMeta:
52
68
 
53
69
  @dataclass(slots=True)
54
70
  class Command:
71
+ """A command callback paired with its registration metadata."""
72
+
55
73
  callback: callable
56
74
  meta: CommandMeta
57
75
  guild_id: Snowflake | None = None
58
76
 
59
77
 
60
78
  class CommandRouter:
79
+ """A collection of commands that can be attached to a bot.
80
+
81
+ Args:
82
+ name: Optional router name used in logs. Inferred from the caller's
83
+ module when omitted.
84
+ guild_id: Scope every command in the router to a single guild. Guild
85
+ scoped commands cannot set ``integration_types`` or ``contexts``.
86
+ integration_types: Where commands can be installed. Use
87
+ :class:`~fastapi_interactions.models.IntegrationType` flags.
88
+ contexts: Where commands can be invoked. Use
89
+ :class:`~fastapi_interactions.models.InteractionContext` flags.
90
+
91
+ Raises:
92
+ ValueError: If a guild-scoped router also sets ``integration_types`` or
93
+ ``contexts``, or if either flag value is empty.
94
+ """
95
+
61
96
  def __init__(
62
97
  self,
63
98
  name: str | None = None,
@@ -133,6 +168,13 @@ class CommandRouter:
133
168
 
134
169
 
135
170
  class Option:
171
+ """Decorators that attach typed options to command callbacks.
172
+
173
+ Each static method registers one Discord option type. Decorate the command
174
+ below ``@router.command(...)`` so the option metadata is collected before
175
+ the command is registered.
176
+ """
177
+
136
178
  @staticmethod
137
179
  def __create_operation_decorator(
138
180
  name: str, description: str, option_type: OptionType, required: bool = True
@@ -157,7 +199,7 @@ class Option:
157
199
  return decorator
158
200
 
159
201
  @staticmethod
160
- def string(name: str, description: str, required: bool = True):
202
+ def string(name: str, description: str, required: bool = True) -> Callable:
161
203
  """Register a string option on a command.
162
204
 
163
205
  Decorates a command callback to add a string option parameter. The option name
@@ -187,7 +229,7 @@ class Option:
187
229
  )
188
230
 
189
231
  @staticmethod
190
- def integer(name: str, description: str, required: bool = True):
232
+ def integer(name: str, description: str, required: bool = True) -> Callable:
191
233
  """Register an integer option on a command.
192
234
 
193
235
  Decorates a command callback to add an integer option parameter. The option name
@@ -217,7 +259,7 @@ class Option:
217
259
  )
218
260
 
219
261
  @staticmethod
220
- def user(name: str, description: str, required: bool = True):
262
+ def user(name: str, description: str, required: bool = True) -> Callable:
221
263
  """Register a user option on a command.
222
264
 
223
265
  Decorates a command callback to add a user option parameter. The option name
@@ -248,7 +290,7 @@ class Option:
248
290
  )
249
291
 
250
292
  @staticmethod
251
- def channel(name: str, description: str, required: bool = True):
293
+ def channel(name: str, description: str, required: bool = True) -> Callable:
252
294
  """Register a channel option on a command.
253
295
 
254
296
  Decorates a command callback to add a channel option parameter. The option name
@@ -279,7 +321,7 @@ class Option:
279
321
  )
280
322
 
281
323
  @staticmethod
282
- def role(name: str, description: str, required: bool = True):
324
+ def role(name: str, description: str, required: bool = True) -> Callable:
283
325
  """Register a role option on a command.
284
326
 
285
327
  Decorates a command callback to add a channel option parameter. The option name
@@ -310,7 +352,7 @@ class Option:
310
352
  )
311
353
 
312
354
  @staticmethod
313
- def mentionable(name: str, description: str, required: bool = True):
355
+ def mentionable(name: str, description: str, required: bool = True) -> Callable:
314
356
  """Register a mentionable option on a command.
315
357
 
316
358
  Decorates a command callback to add a channel option parameter. The option name
@@ -340,7 +382,7 @@ class Option:
340
382
  )
341
383
 
342
384
  @staticmethod
343
- def boolean(name: str, description: str, required: bool = True):
385
+ def boolean(name: str, description: str, required: bool = True) -> Callable:
344
386
  """Register a boolean option on a command.
345
387
 
346
388
  Decorates a command callback to add a boolean option parameter. The option name
@@ -375,7 +417,7 @@ class Option:
375
417
  )
376
418
 
377
419
  @staticmethod
378
- def number(name: str, description: str, required: bool = True):
420
+ def number(name: str, description: str, required: bool = True) -> Callable:
379
421
  """Register a number option on a command.
380
422
 
381
423
  Decorates a command callback to add a number option parameter. The option name
@@ -0,0 +1,83 @@
1
+ """The interaction context passed to command handlers.
2
+
3
+ A :class:`Context` bundles the parsed interaction, the command's option values,
4
+ and an async HTTP client for followup webhook requests.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from .models import Interaction, ApplicationCommandData, User, Snowflake
9
+ from typing import Optional, Any
10
+ import httpx
11
+ from .responses import MessageResponse
12
+
13
+
14
+ @dataclass
15
+ class Context:
16
+ """State and helpers for a single interaction.
17
+
18
+ Attributes:
19
+ interaction: The parsed interaction payload from Discord.
20
+ options: The application command data, including supplied option values.
21
+ http: Async HTTP client used for followup webhook requests.
22
+ """
23
+
24
+ interaction: Interaction
25
+ options: ApplicationCommandData
26
+ http: httpx.AsyncClient
27
+
28
+ @property
29
+ def user(self) -> User:
30
+ """The user who invoked the interaction.
31
+
32
+ Guild interactions expose the user through ``member.user``; direct
33
+ message interactions expose it directly on the interaction.
34
+
35
+ Raises:
36
+ ValueError: If the payload contains neither ``user`` nor ``member.user``.
37
+ """
38
+ if self.interaction.user is not None:
39
+ return self.interaction.user
40
+ if (
41
+ self.interaction.member is not None
42
+ and self.interaction.member.user is not None
43
+ ):
44
+ return self.interaction.member.user
45
+ raise ValueError("Interaction does not contain `user` or `member.user`")
46
+
47
+ @property
48
+ def guild_id(self) -> Optional[Snowflake]:
49
+ """The guild ID where the interaction occurred, or ``None`` in DMs."""
50
+ return self.interaction.guild_id
51
+
52
+ @property
53
+ def channel_id(self) -> Optional[Snowflake]:
54
+ """The channel ID where the interaction occurred, if present."""
55
+ return self.interaction.channel_id
56
+
57
+ @property
58
+ def _webhook_base(self) -> str:
59
+ return f"https://discord.com/api/v10/webhooks/{self.interaction.application_id}/{self.interaction.token}"
60
+
61
+ async def send(self, content: str, ephemeral: bool = False) -> None:
62
+ """Send a followup message through the interaction webhook.
63
+
64
+ Args:
65
+ content: The message text to send.
66
+ ephemeral: Whether the message should only be visible to the
67
+ invoking user. Defaults to ``False``.
68
+ """
69
+ message = MessageResponse(content, ephemeral=ephemeral)
70
+ await self.http.post(url=self._webhook_base, json=message.to_payload())
71
+
72
+ def get_option_value(self, name: str, default: Any = None) -> Any:
73
+ """Return the value of a command option by name.
74
+
75
+ Args:
76
+ name: The option name to look up.
77
+ default: Value returned when the option was not supplied.
78
+
79
+ Returns:
80
+ The option value, or ``default`` when it is missing.
81
+ """
82
+ option = self.options.options_by_name.get(name)
83
+ return option.value if option is not None else default
@@ -1,3 +1,10 @@
1
+ """Middleware for verifying Discord webhook signatures.
2
+
3
+ Discord signs every interaction request with Ed25519. The middleware validates
4
+ the signature before the bot parses or acts on the payload, and stores the raw
5
+ body on ``request.state.raw_body`` for handlers that need it.
6
+ """
7
+
1
8
  from fastapi import Request
2
9
  from fastapi.responses import JSONResponse
3
10
  from starlette.middleware.base import BaseHTTPMiddleware
@@ -8,6 +15,17 @@ from nacl.exceptions import BadSignatureError
8
15
  def verify_signature(
9
16
  public_key: str, signature: str, timestamp: str, body: bytes
10
17
  ) -> bool:
18
+ """Verify a Discord interaction signature.
19
+
20
+ Args:
21
+ public_key: The application's public key as a hex string.
22
+ signature: The ``x-signature-ed25519`` header value.
23
+ timestamp: The ``x-signature-timestamp`` header value.
24
+ body: The raw request body that was signed.
25
+
26
+ Returns:
27
+ ``True`` if the signature is valid, otherwise ``False``.
28
+ """
11
29
  try:
12
30
  VerifyKey(bytes.fromhex(public_key)).verify(
13
31
  timestamp.encode() + body, bytes.fromhex(signature)
@@ -18,6 +36,13 @@ def verify_signature(
18
36
 
19
37
 
20
38
  class VerifySignatureMiddleware(BaseHTTPMiddleware):
39
+ """Reject interaction requests whose Discord signature is invalid.
40
+
41
+ Requests to paths other than ``interaction_path`` pass through unchanged.
42
+ Valid interaction requests have their raw body stored on
43
+ ``request.state.raw_body`` for downstream handlers.
44
+ """
45
+
21
46
  def __init__(self, app, public_key: str, interaction_path: str = "/interactions"):
22
47
  super().__init__(app)
23
48
  self.public_key = public_key
@@ -0,0 +1,286 @@
1
+ """Enums and Pydantic models for Discord interaction payloads.
2
+
3
+ This module mirrors the parts of Discord's interaction and application command
4
+ objects that the framework needs. Enums use the same integer values Discord sends
5
+ and expects, while Pydantic models parse incoming payloads with unknown fields
6
+ ignored.
7
+ """
8
+
9
+ from enum import IntEnum, IntFlag
10
+ from typing import Any, Optional
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ Snowflake = str
15
+
16
+
17
+ class _ApiFlag(IntFlag):
18
+ """Base class for flags that are serialized as Discord's integer lists.
19
+
20
+ Each flag value is a bit position rather than the API integer itself. For
21
+ example, the first flag in a subclass is ``1 << 0`` and is sent to Discord
22
+ as ``0``. Use :meth:`to_api` to convert a combination of flags into the
23
+ list of integers Discord expects.
24
+ """
25
+
26
+ def to_api(self) -> list[int]:
27
+ """Bit positions set in this flag, e.g. GUILD|PRIVATE_CHANNEL -> [0, 2]."""
28
+ return [i for i in range(self.bit_length()) if self & (1 << i)]
29
+
30
+
31
+ class IntegrationType(_ApiFlag):
32
+ """Where a command can be installed and used.
33
+
34
+ Discord calls these *integration types*. Combine values with ``|`` when a
35
+ command should support more than one installation surface, or use
36
+ :attr:`ALL` as a shorthand. The selected flags are serialized to the
37
+ ``integration_types`` field when commands are registered.
38
+
39
+ Attributes:
40
+ GUILD_INSTALL: The command can be installed on a guild (API value ``0``).
41
+ USER_INSTALL: The command can be installed on a user account (API value ``1``).
42
+ ALL: Both guild and user installation (API values ``[0, 1]``).
43
+ """
44
+
45
+ GUILD_INSTALL = 1 << 0 # API VALUE 0
46
+ USER_INSTALL = 1 << 1 # API VALUE 1
47
+ ALL = GUILD_INSTALL | USER_INSTALL
48
+
49
+
50
+ class InteractionContext(_ApiFlag):
51
+ """Where an application command can be invoked.
52
+
53
+ Combine values with ``|`` to allow a command in several places, or use
54
+ :attr:`ALL` as a shorthand. The selected flags are serialized to the
55
+ ``contexts`` field when commands are registered.
56
+
57
+ Attributes:
58
+ GUILD: Usable in guild channels (API value ``0``).
59
+ BOT_DM: Usable in direct messages with the bot (API value ``1``).
60
+ PRIVATE_CHANNEL: Usable in private channels such as group DMs (API value ``2``).
61
+ ALL: Usable in every supported context (API values ``[0, 1, 2]``).
62
+ """
63
+
64
+ GUILD = 1 << 0 # API VALUE 0
65
+ BOT_DM = 1 << 1 # API VALUE 1
66
+ PRIVATE_CHANNEL = 1 << 2 # VALUE VALUE 2
67
+ ALL = GUILD | BOT_DM | PRIVATE_CHANNEL
68
+
69
+
70
+ class InteractionType(IntEnum):
71
+ """Type of interaction delivered to the bot's webhook endpoint.
72
+
73
+ Attributes:
74
+ PING: Discord's endpoint verification request.
75
+ APPLICATION_COMMAND: A slash command invocation.
76
+ MESSAGE_COMPONENT: A button or select menu interaction.
77
+ APPLICATION_COMMAND_AUTOCOMPLETE: An autocomplete request for a command option.
78
+ MODAL_SUBMIT: A submitted modal dialog.
79
+ """
80
+
81
+ PING = 1
82
+ APPLICATION_COMMAND = 2
83
+ MESSAGE_COMPONENT = 3
84
+ APPLICATION_COMMAND_AUTOCOMPLETE = 4
85
+ MODAL_SUBMIT = 5
86
+
87
+
88
+ class CommandType(IntEnum):
89
+ """Type of application command registered with Discord.
90
+
91
+ Attributes:
92
+ CHAT_INPUT: A slash command (API value ``1``).
93
+ USER: A user context menu command (API value ``2``).
94
+ MESSAGE: A message context menu command (API value ``3``).
95
+ PRIMARY_ENTRY_POINT: The application's primary entry point command (API value ``4``).
96
+ CHAT: Alias for :attr:`CHAT_INPUT`.
97
+ ENTRY: Alias for :attr:`PRIMARY_ENTRY_POINT`.
98
+ """
99
+
100
+ CHAT_INPUT = 1
101
+ USER = 2
102
+ MESSAGE = 3
103
+ PRIMARY_ENTRY_POINT = 4
104
+
105
+ CHAT = 1
106
+ ENTRY = 4
107
+
108
+
109
+ class InteractionCallbackType(IntEnum):
110
+ """Response type sent back to Discord for an interaction.
111
+
112
+ Attributes:
113
+ PONG: Acknowledge a PING interaction.
114
+ CHANNEL_MESSAGE_WITH_SOURCE: Send a message in the interaction's channel.
115
+ DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE: Acknowledge and send a message later.
116
+ DEFERRED_UPDATE_MESSAGE: Acknowledge a component interaction and update later.
117
+ UPDATE_MESSAGE: Update the message a component is attached to.
118
+ APPLICATION_COMMAND_AUTOCOMPLETE_RESULT: Return autocomplete choices.
119
+ MODAL: Open a modal dialog.
120
+ PREMIUM_REQUIRED: Tell the user a premium upgrade is required.
121
+ LAUNCH_ACTIVITY: Launch an embedded activity.
122
+ MESSAGE: Alias for :attr:`CHANNEL_MESSAGE_WITH_SOURCE`.
123
+ DEFER: Alias for :attr:`DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE`.
124
+ UPDATE: Alias for :attr:`UPDATE_MESSAGE`.
125
+ AUTOCOMPLETE: Alias for :attr:`APPLICATION_COMMAND_AUTOCOMPLETE_RESULT`.
126
+ """
127
+
128
+ PONG = 1
129
+ CHANNEL_MESSAGE_WITH_SOURCE = 4
130
+ DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5
131
+ DEFERRED_UPDATE_MESSAGE = 6
132
+ UPDATE_MESSAGE = 7
133
+ APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8
134
+ MODAL = 9
135
+ PREMIUM_REQUIRED = 10
136
+ LAUNCH_ACTIVITY = 12
137
+
138
+ MESSAGE = CHANNEL_MESSAGE_WITH_SOURCE
139
+ DEFER = DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
140
+ UPDATE = UPDATE_MESSAGE
141
+ AUTOCOMPLETE = APPLICATION_COMMAND_AUTOCOMPLETE_RESULT
142
+
143
+
144
+ class MessageFlags(IntFlag):
145
+ """Bit flags describing special message properties."""
146
+
147
+ CROSSPOSTED = 1 << 0
148
+ IS_CROSSPOST = 1 << 1
149
+ SUPPRESS_EMBEDS = 1 << 2
150
+ SOURCE_MESSAGE_DELETED = 1 << 3
151
+ URGENT = 1 << 4
152
+ HAS_THREAD = 1 << 5
153
+ EPHEMERAL = 1 << 6
154
+ LOADING = 1 << 7
155
+ FAILED_TO_MENTION_SOME_ROLES_IN_THREAD = 1 << 8
156
+
157
+ SUPPRESS_NOTIFICATIONS = 1 << 12
158
+ IS_VOICE_MESSAGE = 1 << 13
159
+ HAS_SNAPSHOT = 1 << 14
160
+ IS_COMPONENTS_V2 = 1 << 15
161
+
162
+
163
+ class ApplicationCommandOptionType(IntEnum):
164
+ """Type of value accepted by an application command option.
165
+
166
+ Attributes:
167
+ SUB_COMMAND: A nested subcommand.
168
+ SUB_COMMAND_GROUP: A group of related subcommands.
169
+ STRING: A free-form string.
170
+ INTEGER: A whole number.
171
+ BOOLEAN: A true/false value.
172
+ USER: A Discord user.
173
+ CHANNEL: A Discord channel.
174
+ ROLE: A Discord role.
175
+ MENTIONABLE: A user or role mention.
176
+ NUMBER: A floating-point number.
177
+ ATTACHMENT: A file attachment.
178
+ """
179
+
180
+ SUB_COMMAND = 1
181
+ SUB_COMMAND_GROUP = 2
182
+ STRING = 3
183
+ INTEGER = 4
184
+ BOOLEAN = 5
185
+ USER = 6
186
+ CHANNEL = 7
187
+ ROLE = 8
188
+ MENTIONABLE = 9
189
+ NUMBER = 10
190
+ ATTACHMENT = 11
191
+
192
+
193
+ class DiscordModel(BaseModel):
194
+ """Base model for Discord payload objects.
195
+
196
+ Unknown fields are ignored so payloads can gain new properties without
197
+ breaking existing bots.
198
+ """
199
+
200
+ model_config = ConfigDict(extra="ignore")
201
+
202
+
203
+ class User(DiscordModel):
204
+ """A Discord user."""
205
+
206
+ id: Snowflake
207
+ username: str
208
+ descriminator: Optional[str] = None
209
+ global_name: Optional[str] = None
210
+ avatar: Optional[str] = None
211
+ bot: Optional[bool] = None
212
+
213
+
214
+ class Member(DiscordModel):
215
+ """A guild member and their guild-specific attributes."""
216
+
217
+ user: Optional[User] = None
218
+ nick: Optional[str] = None
219
+ roles: list[Snowflake] = Field(default_factory=list)
220
+ permissions: Optional[str] = None
221
+
222
+
223
+ class ApplicationCommandInteractionOption(DiscordModel):
224
+ """A command option value supplied with an interaction.
225
+
226
+ Options may contain nested options for subcommands and subcommand groups.
227
+ """
228
+
229
+ name: str
230
+ type: ApplicationCommandOptionType
231
+ value: Optional[str | int | float | bool] = None
232
+ options: list["ApplicationCommandInteractionOption"] = Field(default_factory=list)
233
+ focused: Optional[bool] = None
234
+
235
+ @property
236
+ def options_by_name(self) -> dict[str, "ApplicationCommandInteractionOption"]:
237
+ return {opt.name for opt in self.options}
238
+
239
+ def get_option_value(self, name: str, default: Any = None) -> Any:
240
+ option = self.options_by_name.get(name)
241
+ return option.value if option is not None else default
242
+
243
+
244
+ ApplicationCommandInteractionOption.model_rebuild()
245
+
246
+
247
+ class ApplicationCommandData(DiscordModel):
248
+ """The command data attached to an application command interaction."""
249
+
250
+ id: Snowflake
251
+ name: str
252
+ type: CommandType
253
+ guild_id: Optional[Snowflake] = None
254
+ target_id: Optional[Snowflake] = None
255
+ options: list[ApplicationCommandInteractionOption] = Field(default_factory=list)
256
+
257
+ @property
258
+ def options_by_name(self) -> dict[str, "ApplicationCommandInteractionOption"]:
259
+ return {opt.name: opt for opt in self.options}
260
+
261
+ def get_option_value(self, name: str, default: Any = None) -> Any:
262
+ option = self.options_by_name.get(name)
263
+ return option.value if option is not None else default
264
+
265
+
266
+ class Interaction(DiscordModel):
267
+ """An interaction payload received from Discord.
268
+
269
+ The framework validates the raw webhook body into this model before building
270
+ a :class:`fastapi_interactions.context.Context` for command handlers.
271
+ """
272
+
273
+ id: Snowflake
274
+ application_id: Snowflake
275
+ type: InteractionType
276
+ token: str
277
+ version: int
278
+
279
+ data: Optional[dict[str, Any]] = None
280
+
281
+ guild_id: Optional[Snowflake] = None
282
+ channel_id: Optional[Snowflake] = None
283
+ member: Optional[Member] = None
284
+ user: Optional[User] = None
285
+ locale: Optional[str] = None
286
+ guild_locale: Optional[str] = None
@@ -0,0 +1,104 @@
1
+ """Interaction response objects returned by command handlers.
2
+
3
+ Each response serializes itself into the callback payload Discord expects. The
4
+ bot converts returned strings into :class:`MessageResponse` instances and wraps
5
+ responses in a JSON HTTP response.
6
+ """
7
+
8
+ import asyncio
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import Callable
11
+ from dataclasses import InitVar, dataclass, field
12
+ from typing import Any
13
+
14
+ from fastapi.responses import JSONResponse
15
+
16
+ from .models import (
17
+ InteractionCallbackType,
18
+ MessageFlags,
19
+ )
20
+
21
+
22
+ class _JSONResponse(JSONResponse):
23
+ """A JSON response that serializes :class:`InteractionResponse` objects."""
24
+
25
+ def render(self, content: Any) -> bytes:
26
+ if isinstance(content, InteractionResponse):
27
+ content = content.to_dict()
28
+ return super().render(content)
29
+
30
+
31
+ class InteractionResponse(ABC):
32
+ """Base class for all interaction callback responses."""
33
+
34
+ @abstractmethod
35
+ def to_dict(self):
36
+ """Return the Discord callback payload for this response."""
37
+ pass
38
+
39
+ async def __call__(self) -> dict:
40
+ return self.to_dict()
41
+
42
+
43
+ class PongResponse(InteractionResponse):
44
+ """Acknowledge a PING interaction.
45
+
46
+ The bot sends this automatically when Discord verifies the interactions
47
+ endpoint, so handlers normally never need to create it.
48
+ """
49
+
50
+ callback_type = InteractionCallbackType.PONG
51
+
52
+ def to_dict(self):
53
+ return {"type": self.callback_type}
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class MessageResponse(InteractionResponse):
58
+ """Send a message in the interaction's channel.
59
+
60
+ Args:
61
+ content: The message text.
62
+ ephemeral: Whether only the invoking user can see the message.
63
+ Defaults to ``False``.
64
+ """
65
+
66
+ callback_type = InteractionCallbackType.MESSAGE
67
+ content: str
68
+ ephemeral: InitVar[bool] = False
69
+ flags: MessageFlags = field(init=False)
70
+
71
+ def __post_init__(self, ephemeral: bool):
72
+ self.flags = MessageFlags.EPHEMERAL if ephemeral else MessageFlags(0)
73
+
74
+ def to_payload(self):
75
+ return {"content": self.content, "flags": int(self.flags)}
76
+
77
+ def to_dict(self):
78
+ return {"type": self.callback_type, "data": self.to_payload()}
79
+
80
+
81
+ @dataclass(slots=True)
82
+ class DeferResponse(InteractionResponse):
83
+ """Acknowledge an interaction now and deliver the real response later.
84
+
85
+ The bot sends the defer acknowledgement before running ``finish`` as a
86
+ background task. Use :meth:`fastapi_interactions.context.Context.send`
87
+ inside ``finish`` to deliver followup messages.
88
+
89
+ Args:
90
+ ephemeral: Whether followup messages should be ephemeral. Defaults to
91
+ ``False``.
92
+ finish: Optional callback run in the background after the defer is sent.
93
+ """
94
+
95
+ callback_type = InteractionCallbackType.DEFER
96
+ ephemeral: InitVar[bool] = False
97
+ finish: Callable | None = field(default=None, repr=None, compare=None)
98
+ flags: MessageFlags = field(init=False)
99
+
100
+ def __post_init__(self, ephemeral: bool):
101
+ self.flags = MessageFlags.EPHEMERAL if ephemeral else MessageFlags(0)
102
+
103
+ def to_dict(self):
104
+ return {"type": self.callback_type, "data": {"flags": int(self.flags)}}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-interactions
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: A lightweight framework for discord interactions over http built on FastAPI
5
5
  Author-email: Haider Ali <haideralidevnull@gmail.com>
6
6
  Requires-Python: >=3.13
@@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'
4
4
 
5
5
  [project]
6
6
  name = 'fastapi-interactions'
7
- version = '0.1.0'
7
+ version = '0.1.1'
8
8
  description='A lightweight framework for discord interactions over http built on FastAPI'
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -28,4 +28,4 @@ docs = [
28
28
 
29
29
  [tool.setuptools.packages.find]
30
30
  include = ['fastapi_interactions*']
31
- exclude = ['docs*', 'exts*', 'tests*']
31
+ exclude = ['docs*', 'exts*', 'tests*']
@@ -1,43 +0,0 @@
1
- from dataclasses import dataclass
2
- from .models import Interaction, ApplicationCommandData, User, Snowflake
3
- from typing import Optional, Any
4
- import httpx
5
- from .responses import MessageResponse
6
-
7
-
8
- @dataclass
9
- class Context:
10
- interaction: Interaction
11
- options: ApplicationCommandData
12
- http: httpx.AsyncClient
13
-
14
- @property
15
- def user(self) -> User:
16
- if self.interaction.user is not None:
17
- return self.interaction.user
18
- if (
19
- self.interaction.member is not None
20
- and self.interaction.member.user is not None
21
- ):
22
- return self.interaction.member.user
23
- raise ValueError("Interaction does not contain `user` or `member.user`")
24
-
25
- @property
26
- def guild_id(self) -> Optional[Snowflake]:
27
- return self.interaction.guild_id
28
-
29
- @property
30
- def channel_id(self) -> Optional[Snowflake]:
31
- return self.interaction.channel_id
32
-
33
- @property
34
- def _webhook_base(self) -> str:
35
- return f"https://discord.com/api/v10/webhooks/{self.interaction.application_id}/{self.interaction.token}"
36
-
37
- async def send(self, content: str, ephemeral: bool = False) -> None:
38
- message = MessageResponse(content, ephemeral=ephemeral)
39
- await self.http.post(url=self._webhook_base, json=message.to_payload())
40
-
41
- def get_option_value(self, name: str, default: Any = None) -> Any:
42
- option = self.options.options_by_name.get(name)
43
- return option.value if option is not None else default
@@ -1,166 +0,0 @@
1
- from enum import IntEnum, IntFlag
2
- from typing import Any, Optional
3
-
4
- from pydantic import BaseModel, ConfigDict, Field
5
-
6
- Snowflake = str
7
-
8
-
9
- class _ApiFlag(IntFlag):
10
- def to_api(self) -> list[int]:
11
- """Bit positions set in this flag, e.g. GUILD|PRIVATE_CHANNEL -> [0, 2]."""
12
- return [i for i in range(self.bit_length()) if self & (1 << i)]
13
-
14
-
15
- class IntegrationType(_ApiFlag):
16
- GUILD_INSTALL = 1 << 0 # API VALUE 0
17
- USER_INSTALL = 1 << 1 # API VALUE 1
18
- ALL = GUILD_INSTALL | USER_INSTALL
19
-
20
-
21
- class InteractionContext(_ApiFlag):
22
- GUILD = 1 << 0 # API VALUE 0
23
- BOT_DM = 1 << 1 # API VALUE 1
24
- PRIVATE_CHANNEL = 1 << 2 # VALUE VALUE 2
25
- ALL = GUILD | BOT_DM | PRIVATE_CHANNEL
26
-
27
-
28
- class InteractionType(IntEnum):
29
- PING = 1
30
- APPLICATION_COMMAND = 2
31
- MESSAGE_COMPONENT = 3
32
- APPLICATION_COMMAND_AUTOCOMPLETE = 4
33
- MODAL_SUBMIT = 5
34
-
35
-
36
- class CommandType(IntEnum):
37
- CHAT_INPUT = 1
38
- USER = 2
39
- MESSAGE = 3
40
- PRIMARY_ENTRY_POINT = 4
41
-
42
- CHAT = 1
43
- ENTRY = 4
44
-
45
-
46
- class InteractionCallbackType(IntEnum):
47
- PONG = 1
48
- CHANNEL_MESSAGE_WITH_SOURCE = 4
49
- DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5
50
- DEFERRED_UPDATE_MESSAGE = 6
51
- UPDATE_MESSAGE = 7
52
- APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8
53
- MODAL = 9
54
- PREMIUM_REQUIRED = 10
55
- LAUNCH_ACTIVITY = 12
56
-
57
- MESSAGE = CHANNEL_MESSAGE_WITH_SOURCE
58
- DEFER = DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
59
- UPDATE = UPDATE_MESSAGE
60
- AUTOCOMPLETE = APPLICATION_COMMAND_AUTOCOMPLETE_RESULT
61
-
62
-
63
- class MessageFlags(IntFlag):
64
- """Bit flags describing special message properties."""
65
-
66
- CROSSPOSTED = 1 << 0
67
- IS_CROSSPOST = 1 << 1
68
- SUPPRESS_EMBEDS = 1 << 2
69
- SOURCE_MESSAGE_DELETED = 1 << 3
70
- URGENT = 1 << 4
71
- HAS_THREAD = 1 << 5
72
- EPHEMERAL = 1 << 6
73
- LOADING = 1 << 7
74
- FAILED_TO_MENTION_SOME_ROLES_IN_THREAD = 1 << 8
75
-
76
- SUPPRESS_NOTIFICATIONS = 1 << 12
77
- IS_VOICE_MESSAGE = 1 << 13
78
- HAS_SNAPSHOT = 1 << 14
79
- IS_COMPONENTS_V2 = 1 << 15
80
-
81
-
82
- class ApplicationCommandOptionType(IntEnum):
83
- SUB_COMMAND = 1
84
- SUB_COMMAND_GROUP = 2
85
- STRING = 3
86
- INTEGER = 4
87
- BOOLEAN = 5
88
- USER = 6
89
- CHANNEL = 7
90
- ROLE = 8
91
- MENTIONABLE = 9
92
- NUMBER = 10
93
- ATTACHMENT = 11
94
-
95
-
96
- class DiscordModel(BaseModel):
97
- model_config = ConfigDict(extra="ignore")
98
-
99
-
100
- class User(DiscordModel):
101
- id: Snowflake
102
- username: str
103
- descriminator: Optional[str] = None
104
- global_name: Optional[str] = None
105
- avatar: Optional[str] = None
106
- bot: Optional[bool] = None
107
-
108
-
109
- class Member(DiscordModel):
110
- user: Optional[User] = None
111
- nick: Optional[str] = None
112
- roles: list[Snowflake] = Field(default_factory=list)
113
- permissions: Optional[str] = None
114
-
115
-
116
- class ApplicationCommandInteractionOption(DiscordModel):
117
- name: str
118
- type: ApplicationCommandOptionType
119
- value: Optional[str | int | float | bool] = None
120
- options: list["ApplicationCommandInteractionOption"] = Field(default_factory=list)
121
- focused: Optional[bool] = None
122
-
123
- @property
124
- def options_by_name(self) -> dict[str, "ApplicationCommandInteractionOption"]:
125
- return {opt.name for opt in self.options}
126
-
127
- def get_option_value(self, name: str, default: Any = None) -> Any:
128
- option = self.options_by_name.get(name)
129
- return option.value if option is not None else default
130
-
131
-
132
- ApplicationCommandInteractionOption.model_rebuild()
133
-
134
-
135
- class ApplicationCommandData(DiscordModel):
136
- id: Snowflake
137
- name: str
138
- type: CommandType
139
- guild_id: Optional[Snowflake] = None
140
- target_id: Optional[Snowflake] = None
141
- options: list[ApplicationCommandInteractionOption] = Field(default_factory=list)
142
-
143
- @property
144
- def options_by_name(self) -> dict[str, "ApplicationCommandInteractionOption"]:
145
- return {opt.name: opt for opt in self.options}
146
-
147
- def get_option_value(self, name: str, default: Any = None) -> Any:
148
- option = self.options_by_name.get(name)
149
- return option.value if option is not None else default
150
-
151
-
152
- class Interaction(DiscordModel):
153
- id: Snowflake
154
- application_id: Snowflake
155
- type: InteractionType
156
- token: str
157
- version: int
158
-
159
- data: Optional[dict[str, Any]] = None
160
-
161
- guild_id: Optional[Snowflake] = None
162
- channel_id: Optional[Snowflake] = None
163
- member: Optional[Member] = None
164
- user: Optional[User] = None
165
- locale: Optional[str] = None
166
- guild_locale: Optional[str] = None
@@ -1,61 +0,0 @@
1
- from dataclasses import dataclass
2
- from abc import ABC, abstractmethod
3
- from .models import (
4
- InteractionCallbackType,
5
- MessageFlags,
6
- )
7
- import asyncio
8
-
9
-
10
- class InteractionResponse(ABC):
11
-
12
- @abstractmethod
13
- def to_dict(self):
14
- pass
15
-
16
- async def __call__(self) -> dict:
17
- return self.to_dict()
18
-
19
- def __await__(self):
20
- return self.__call__().__await__()
21
-
22
-
23
- class PongResponse(InteractionResponse):
24
- callback_type = InteractionCallbackType.PONG
25
-
26
- def to_dict(self):
27
- return {"type": self.callback_type}
28
-
29
-
30
- @dataclass(slots=True)
31
- class MessageResponse(InteractionResponse):
32
- callback_type = InteractionCallbackType.MESSAGE
33
- content: str
34
- ephemeral: bool = False
35
-
36
- def __init__(self, content: str, ephemeral: bool = False):
37
- self.content = content
38
- self.flags = MessageFlags.EPHEMERAL if ephemeral else MessageFlags(0)
39
-
40
- def to_payload(self):
41
- return {"content": self.content, "flags": int(self.flags)}
42
-
43
- def to_dict(self):
44
- return {"type": self.callback_type, "data": self.to_payload()}
45
-
46
-
47
- @dataclass(slots=True)
48
- class DeferResponse(InteractionResponse):
49
- callback_type = InteractionCallbackType.DEFER
50
-
51
- def __init__(self, ephemeral: bool = False, finish: callable = None):
52
- self.flags = MessageFlags.EPHEMERAL if ephemeral else MessageFlags(0)
53
- self.finish = finish
54
-
55
- def to_dict(self):
56
- return {"type": self.callback_type, "data": {"flags": int(self.flags)}}
57
-
58
- async def __call__(self):
59
- if self.finish:
60
- asyncio.create_task(self.finish())
61
- return self.to_dict()