fastapi-interactions 0.0.1__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.0.1 → fastapi_interactions-0.1.1}/PKG-INFO +1 -1
  2. {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions/__init__.py +2 -0
  3. {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions/bot.py +63 -23
  4. {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions/commands.py +110 -28
  5. fastapi_interactions-0.1.1/fastapi_interactions/context.py +83 -0
  6. {fastapi_interactions-0.0.1 → 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.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/PKG-INFO +1 -1
  10. {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/pyproject.toml +2 -2
  11. fastapi_interactions-0.0.1/fastapi_interactions/context.py +0 -43
  12. fastapi_interactions-0.0.1/fastapi_interactions/models.py +0 -146
  13. fastapi_interactions-0.0.1/fastapi_interactions/responses.py +0 -61
  14. {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/README.md +0 -0
  15. {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/SOURCES.txt +0 -0
  16. {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/dependency_links.txt +0 -0
  17. {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/requires.txt +0 -0
  18. {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/top_level.txt +0 -0
  19. {fastapi_interactions-0.0.1 → 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.0.1
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,24 +1,40 @@
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
+
8
+ import importlib
9
+ import inspect
10
+ import json
11
+ import pkgutil
12
+ import types
13
+ from typing import Any
14
+
15
+ import httpx
1
16
  from fastapi import FastAPI, Request
2
- from .middleware import VerifySignatureMiddleware
3
- from .responses import InteractionResponse, PongResponse, MessageResponse
17
+ from fastapi.responses import JSONResponse
18
+ from loguru import logger
19
+ from pydantic import ValidationError
20
+ from starlette.background import BackgroundTask
21
+
22
+ from .commands import Command, CommandRouter
4
23
  from .context import Context
24
+ from .middleware import VerifySignatureMiddleware
5
25
  from .models import (
6
- InteractionType,
7
26
  ApplicationCommandData,
8
27
  Interaction,
28
+ InteractionType,
9
29
  Snowflake,
10
30
  )
11
- from typing import Any
12
- from .commands import Command
13
- from pydantic import ValidationError
14
- from .commands import CommandRouter
15
- import json
16
- import httpx
17
- import importlib
18
- import pkgutil
19
- from loguru import logger
20
- import inspect
21
- import types
31
+ from .responses import (
32
+ DeferResponse,
33
+ InteractionResponse,
34
+ MessageResponse,
35
+ PongResponse,
36
+ _JSONResponse,
37
+ )
22
38
 
23
39
 
24
40
  async def call_with_options(callback: callable, ctx: Context) -> Any:
@@ -60,6 +76,14 @@ async def call_with_options(callback: callable, ctx: Context) -> Any:
60
76
 
61
77
 
62
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
+
63
87
  def __init__(
64
88
  self,
65
89
  app_id: int,
@@ -94,7 +118,7 @@ class Bot:
94
118
  payload = json.loads(request.state.raw_body)
95
119
 
96
120
  if payload["type"] == InteractionType.PING:
97
- return await PongResponse()
121
+ return _JSONResponse(PongResponse())
98
122
 
99
123
  if payload["type"] == InteractionType.APPLICATION_COMMAND:
100
124
  """Construct Context"""
@@ -103,9 +127,8 @@ class Bot:
103
127
  application_command = ApplicationCommandData.model_validate(
104
128
  interaction.data
105
129
  )
106
- except (ValidationError, Exception):
107
- # print(e.errors())
108
- return await MessageResponse("Unexpected error", ephemeral=True)
130
+ except (ValidationError, Exception) as e:
131
+ return _JSONResponse(MessageResponse("Unexpected error", ephemeral=True))
109
132
 
110
133
  context = Context(
111
134
  interaction=interaction, options=application_command, http=self.http
@@ -115,7 +138,13 @@ class Bot:
115
138
  command_name=application_command.name, ctx=context
116
139
  )
117
140
 
118
- return await MessageResponse("Unsupported interaction received", ephemeral=True)
141
+ return _JSONResponse(MessageResponse("Unsupported interaction received", ephemeral=True))
142
+
143
+ async def add_error_handler(self, exc: Exception, func: callable):
144
+ pass
145
+
146
+ async def _handle_exception(self, request: Request, exc: Exception):
147
+ return _JSONResponse(MessageResponse(str(exc), ephemeral=True))
119
148
 
120
149
  def _register_routes(self) -> None:
121
150
  """Set up middleware and the interactions endpoint.
@@ -124,6 +153,7 @@ class Bot:
124
153
  for the interactions endpoint. Called during initialization.
125
154
  """
126
155
  self.app.add_middleware(VerifySignatureMiddleware, public_key=self.public_key)
156
+ self.app.add_exception_handler(Exception, self._handle_exception)
127
157
  self.app.add_api_route(
128
158
  self.interactions_path, endpoint=self.process_interactions, methods=["POST"]
129
159
  )
@@ -279,14 +309,24 @@ class Bot:
279
309
  """
280
310
  command = self.commands.get(command_name)
281
311
  if command is None:
282
- return await MessageResponse("Unknown command", ephemeral=True)
312
+ return _JSONResponse(MessageResponse("Unknown command", ephemeral=True))
283
313
 
284
314
  result = await call_with_options(command.callback, ctx)
285
315
 
286
- if not isinstance(result, InteractionResponse):
287
- 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)
288
323
 
289
- 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)
290
330
 
291
331
  def delete_all_commands(self, guild_id: Snowflake = None) -> None:
292
332
  """
@@ -1,13 +1,28 @@
1
- from dataclasses import dataclass, field
2
- from .models import Snowflake, ApplicationCommandOptionType
3
- from typing import Optional
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
+
4
8
  import sys
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass, field
11
+
12
+ from .models import (
13
+ ApplicationCommandOptionType,
14
+ IntegrationType,
15
+ InteractionContext,
16
+ Snowflake,
17
+ )
5
18
 
6
19
  OptionType = ApplicationCommandOptionType
7
20
 
8
21
 
9
22
  @dataclass
10
23
  class CommandOption:
24
+ """A single option declared on a command."""
25
+
11
26
  name: str
12
27
  description: str
13
28
  type: OptionType = OptionType.STRING
@@ -24,31 +39,86 @@ class CommandOption:
24
39
 
25
40
  @dataclass(slots=True)
26
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
+
27
48
  name: str
28
49
  description: str
29
50
  options: list[CommandOption] = field(default_factory=list)
30
51
  type: int = 1
52
+ integration_types: IntegrationType | None = None
53
+ contexts: InteractionContext | None = None
31
54
 
32
55
  def as_payload(self):
33
- return {
56
+ payload = {
34
57
  "name": self.name,
35
58
  "description": self.description,
36
59
  "type": self.type,
37
60
  "options": [option.as_payload() for option in self.options],
38
61
  }
62
+ if self.integration_types is not None:
63
+ payload["integration_types"] = self.integration_types.to_api()
64
+ if self.contexts is not None:
65
+ payload["contexts"] = self.contexts.to_api()
66
+ return payload
39
67
 
40
68
 
41
69
  @dataclass(slots=True)
42
70
  class Command:
71
+ """A command callback paired with its registration metadata."""
72
+
43
73
  callback: callable
44
74
  meta: CommandMeta
45
- guild_id: Optional[Snowflake] = None
75
+ guild_id: Snowflake | None = None
46
76
 
47
77
 
48
78
  class CommandRouter:
49
- def __init__(self, name: str = None, guild_id: Optional[Snowflake] = None):
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
+
96
+ def __init__(
97
+ self,
98
+ name: str | None = None,
99
+ guild_id: Snowflake | None = None,
100
+ *,
101
+ integration_types: IntegrationType | None = None,
102
+ contexts: InteractionContext | None = None,
103
+ ):
50
104
  self.name = name or self.__infer_name()
51
- self.guild_id: Optional[Snowflake] = guild_id
105
+ self.guild_id = guild_id
106
+
107
+ if guild_id is not None and (
108
+ integration_types is not None or contexts is not None
109
+ ):
110
+ raise ValueError(
111
+ f"Router {self.name} is guild scoped. Cannt bind integration/interaction types"
112
+ )
113
+
114
+ if integration_types is not None and not integration_types:
115
+ raise ValueError("integration types cannot be empty;")
116
+
117
+ if contexts is not None and not contexts:
118
+ raise ValueError("contexts cannot be empty")
119
+
120
+ self.integration_types = integration_types
121
+ self.contexts = contexts
52
122
  self.commands: dict[str, Command] = {}
53
123
 
54
124
  def __infer_name(self) -> str:
@@ -86,6 +156,8 @@ class CommandRouter:
86
156
  )
87
157
  meta.name = name
88
158
  meta.description = description
159
+ meta.integration_types = self.integration_types
160
+ meta.contexts = self.contexts
89
161
  self.commands[name] = Command(
90
162
  callback=func, meta=meta, guild_id=self.guild_id
91
163
  )
@@ -96,11 +168,20 @@ class CommandRouter:
96
168
 
97
169
 
98
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
+
99
178
  @staticmethod
100
- def __create_operation_decorator(name: str, description: str, option_type: OptionType, required: bool = True):
179
+ def __create_operation_decorator(
180
+ name: str, description: str, option_type: OptionType, required: bool = True
181
+ ):
101
182
  def decorator(func):
102
183
  meta = getattr(
103
- func, '_command_meta', CommandMeta(name='', description='', type=1)
184
+ func, "_command_meta", CommandMeta(name="", description="", type=1)
104
185
  )
105
186
 
106
187
  meta.options.insert(
@@ -109,15 +190,16 @@ class Option:
109
190
  name=name,
110
191
  description=description,
111
192
  type=option_type,
112
- required=required
113
- )
193
+ required=required,
194
+ ),
114
195
  )
115
196
  func._command_meta = meta
116
197
  return func
198
+
117
199
  return decorator
118
200
 
119
201
  @staticmethod
120
- def string(name: str, description: str, required: bool = True):
202
+ def string(name: str, description: str, required: bool = True) -> Callable:
121
203
  """Register a string option on a command.
122
204
 
123
205
  Decorates a command callback to add a string option parameter. The option name
@@ -143,11 +225,11 @@ class Option:
143
225
  name=name,
144
226
  description=description,
145
227
  option_type=OptionType.STRING,
146
- required=required
228
+ required=required,
147
229
  )
148
230
 
149
231
  @staticmethod
150
- def integer(name: str, description: str, required: bool = True):
232
+ def integer(name: str, description: str, required: bool = True) -> Callable:
151
233
  """Register an integer option on a command.
152
234
 
153
235
  Decorates a command callback to add an integer option parameter. The option name
@@ -173,11 +255,11 @@ class Option:
173
255
  name=name,
174
256
  description=description,
175
257
  option_type=OptionType.INTEGER,
176
- required=required
258
+ required=required,
177
259
  )
178
260
 
179
261
  @staticmethod
180
- def user(name: str, description: str, required: bool = True):
262
+ def user(name: str, description: str, required: bool = True) -> Callable:
181
263
  """Register a user option on a command.
182
264
 
183
265
  Decorates a command callback to add a user option parameter. The option name
@@ -204,11 +286,11 @@ class Option:
204
286
  name=name,
205
287
  description=description,
206
288
  option_type=OptionType.USER,
207
- required=required
289
+ required=required,
208
290
  )
209
291
 
210
292
  @staticmethod
211
- def channel(name: str, description: str, required: bool = True):
293
+ def channel(name: str, description: str, required: bool = True) -> Callable:
212
294
  """Register a channel option on a command.
213
295
 
214
296
  Decorates a command callback to add a channel option parameter. The option name
@@ -235,11 +317,11 @@ class Option:
235
317
  name=name,
236
318
  description=description,
237
319
  option_type=OptionType.CHANNEL,
238
- required=required
320
+ required=required,
239
321
  )
240
322
 
241
323
  @staticmethod
242
- def role(name: str, description: str, required: bool = True):
324
+ def role(name: str, description: str, required: bool = True) -> Callable:
243
325
  """Register a role option on a command.
244
326
 
245
327
  Decorates a command callback to add a channel option parameter. The option name
@@ -266,11 +348,11 @@ class Option:
266
348
  name=name,
267
349
  description=description,
268
350
  option_type=OptionType.ROLE,
269
- required=required
351
+ required=required,
270
352
  )
271
353
 
272
354
  @staticmethod
273
- def mentionable(name: str, description: str, required: bool = True):
355
+ def mentionable(name: str, description: str, required: bool = True) -> Callable:
274
356
  """Register a mentionable option on a command.
275
357
 
276
358
  Decorates a command callback to add a channel option parameter. The option name
@@ -296,11 +378,11 @@ class Option:
296
378
  name=name,
297
379
  description=description,
298
380
  option_type=OptionType.MENTIONABLE,
299
- required=required
381
+ required=required,
300
382
  )
301
383
 
302
384
  @staticmethod
303
- def boolean(name: str, description: str, required: bool = True):
385
+ def boolean(name: str, description: str, required: bool = True) -> Callable:
304
386
  """Register a boolean option on a command.
305
387
 
306
388
  Decorates a command callback to add a boolean option parameter. The option name
@@ -331,11 +413,11 @@ class Option:
331
413
  name=name,
332
414
  description=description,
333
415
  option_type=OptionType.BOOLEAN,
334
- required=required
416
+ required=required,
335
417
  )
336
418
 
337
419
  @staticmethod
338
- def number(name: str, description: str, required: bool = True):
420
+ def number(name: str, description: str, required: bool = True) -> Callable:
339
421
  """Register a number option on a command.
340
422
 
341
423
  Decorates a command callback to add a number option parameter. The option name
@@ -362,5 +444,5 @@ class Option:
362
444
  name=name,
363
445
  description=description,
364
446
  option_type=OptionType.NUMBER,
365
- required=required
366
- )
447
+ required=required,
448
+ )
@@ -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.0.1
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.0.1'
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,146 +0,0 @@
1
- from pydantic import BaseModel, ConfigDict, Field
2
- from typing import Optional, Any
3
- from enum import IntEnum, IntFlag
4
-
5
- Snowflake = str
6
-
7
-
8
- class InteractionType(IntEnum):
9
- PING = 1
10
- APPLICATION_COMMAND = 2
11
- MESSAGE_COMPONENT = 3
12
- APPLICATION_COMMAND_AUTOCOMPLETE = 4
13
- MODAL_SUBMIT = 5
14
-
15
-
16
- class CommandType(IntEnum):
17
- CHAT_INPUT = 1
18
- USER = 2
19
- MESSAGE = 3
20
- PRIMARY_ENTRY_POINT = 4
21
-
22
- CHAT = 1
23
- ENTRY = 4
24
-
25
-
26
- class InteractionCallbackType(IntEnum):
27
- PONG = 1
28
- CHANNEL_MESSAGE_WITH_SOURCE = 4
29
- DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5
30
- DEFERRED_UPDATE_MESSAGE = 6
31
- UPDATE_MESSAGE = 7
32
- APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8
33
- MODAL = 9
34
- PREMIUM_REQUIRED = 10
35
- LAUNCH_ACTIVITY = 12
36
-
37
- MESSAGE = CHANNEL_MESSAGE_WITH_SOURCE
38
- DEFER = DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
39
- UPDATE = UPDATE_MESSAGE
40
- AUTOCOMPLETE = APPLICATION_COMMAND_AUTOCOMPLETE_RESULT
41
-
42
-
43
- class MessageFlags(IntFlag):
44
- """Bit flags describing special message properties."""
45
-
46
- CROSSPOSTED = 1 << 0
47
- IS_CROSSPOST = 1 << 1
48
- SUPPRESS_EMBEDS = 1 << 2
49
- SOURCE_MESSAGE_DELETED = 1 << 3
50
- URGENT = 1 << 4
51
- HAS_THREAD = 1 << 5
52
- EPHEMERAL = 1 << 6
53
- LOADING = 1 << 7
54
- FAILED_TO_MENTION_SOME_ROLES_IN_THREAD = 1 << 8
55
-
56
- SUPPRESS_NOTIFICATIONS = 1 << 12
57
- IS_VOICE_MESSAGE = 1 << 13
58
- HAS_SNAPSHOT = 1 << 14
59
- IS_COMPONENTS_V2 = 1 << 15
60
-
61
-
62
- class ApplicationCommandOptionType(IntEnum):
63
- SUB_COMMAND = 1
64
- SUB_COMMAND_GROUP = 2
65
- STRING = 3
66
- INTEGER = 4
67
- BOOLEAN = 5
68
- USER = 6
69
- CHANNEL = 7
70
- ROLE = 8
71
- MENTIONABLE = 9
72
- NUMBER = 10
73
- ATTACHMENT = 11
74
-
75
-
76
- class DiscordModel(BaseModel):
77
- model_config = ConfigDict(extra="ignore")
78
-
79
-
80
- class User(DiscordModel):
81
- id: Snowflake
82
- username: str
83
- descriminator: Optional[str] = None
84
- global_name: Optional[str] = None
85
- avatar: Optional[str] = None
86
- bot: Optional[bool] = None
87
-
88
-
89
- class Member(DiscordModel):
90
- user: Optional[User] = None
91
- nick: Optional[str] = None
92
- roles: list[Snowflake] = Field(default_factory=list)
93
- permissions: Optional[str] = None
94
-
95
-
96
- class ApplicationCommandInteractionOption(DiscordModel):
97
- name: str
98
- type: ApplicationCommandOptionType
99
- value: Optional[str | int | float | bool] = None
100
- options: list["ApplicationCommandInteractionOption"] = Field(default_factory=list)
101
- focused: Optional[bool] = None
102
-
103
- @property
104
- def options_by_name(self) -> dict[str, "ApplicationCommandInteractionOption"]:
105
- return {opt.name for opt in self.options}
106
-
107
- def get_option_value(self, name: str, default: Any = None) -> Any:
108
- option = self.options_by_name.get(name)
109
- return option.value if option is not None else default
110
-
111
-
112
- ApplicationCommandInteractionOption.model_rebuild()
113
-
114
-
115
- class ApplicationCommandData(DiscordModel):
116
- id: Snowflake
117
- name: str
118
- type: CommandType
119
- guild_id: Optional[Snowflake] = None
120
- target_id: Optional[Snowflake] = None
121
- options: list[ApplicationCommandInteractionOption] = Field(default_factory=list)
122
-
123
- @property
124
- def options_by_name(self) -> dict[str, "ApplicationCommandInteractionOption"]:
125
- return {opt.name: opt 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
- class Interaction(DiscordModel):
133
- id: Snowflake
134
- application_id: Snowflake
135
- type: InteractionType
136
- token: str
137
- version: int
138
-
139
- data: Optional[dict[str, Any]] = None
140
-
141
- guild_id: Optional[Snowflake] = None
142
- channel_id: Optional[Snowflake] = None
143
- member: Optional[Member] = None
144
- user: Optional[User] = None
145
- locale: Optional[str] = None
146
- 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()