fastapi-interactions 0.0.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.
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-interactions
3
+ Version: 0.0.1
4
+ Summary: A lightweight framework for discord interactions over http built on FastAPI
5
+ Author-email: Haider Ali <haideralidevnull@gmail.com>
6
+ Requires-Python: >=3.13
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: fastapi[standard]>=0.138.0
9
+ Requires-Dist: pydantic>=2.13.4
10
+ Requires-Dist: pynacl>=1.6.2
11
+ Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: loguru>=0.7.3
13
+ Provides-Extra: docs
14
+ Requires-Dist: mkdocs; extra == "docs"
15
+ Requires-Dist: mkdocstrings[python]; extra == "docs"
16
+ Requires-Dist: mkdocs-material; extra == "docs"
17
+
18
+ <a href='https://fastapi-interactions.readthedocs.io/en/latest/' target='_blank'>Read the docs here</a>
19
+
20
+ # Install fastapi-interactions
21
+
22
+ ```
23
+ pip install fastapi-interactions
24
+ ```
25
+
26
+ # Quick example
27
+
28
+ ```python
29
+ from fastapi_interactions import Bot
30
+ from fastapi_interactions.commands import (
31
+ CommandRouter, option,
32
+ )
33
+
34
+ bot = Bot(app_id='DISCORD_APP_ID',
35
+ public_key='DISCORD_PUBLIC_KEY',
36
+ bot_token='DISCORD_BOT_TOKEN')
37
+
38
+ router = CommandRouter()
39
+
40
+
41
+ @router.command('helloworld', 'say hello')
42
+ async def hello(ctx):
43
+ return 'hello'
44
+
45
+
46
+ @router.command('echo', 'echo a phrase back')
47
+ @option('text', 'the text to repeat')
48
+ async def echo(ctx, text: str):
49
+ return text
50
+
51
+ bot.attach_router(router)
52
+
53
+ bot.sync_commands() # Use only on build time if running on vercel
54
+ app = bot.app
55
+
56
+ ```
57
+ ---
58
+
59
+ > [!WARNING]
60
+ > ### SYNCING COMMANDS
61
+ > If you are on a serverless architecture like Vercel, make sure you only call `bot.sync_commands` during build time. You do not want this being executed every time you receive an interaction in production.
62
+
63
+ ---
@@ -0,0 +1,46 @@
1
+ <a href='https://fastapi-interactions.readthedocs.io/en/latest/' target='_blank'>Read the docs here</a>
2
+
3
+ # Install fastapi-interactions
4
+
5
+ ```
6
+ pip install fastapi-interactions
7
+ ```
8
+
9
+ # Quick example
10
+
11
+ ```python
12
+ from fastapi_interactions import Bot
13
+ from fastapi_interactions.commands import (
14
+ CommandRouter, option,
15
+ )
16
+
17
+ bot = Bot(app_id='DISCORD_APP_ID',
18
+ public_key='DISCORD_PUBLIC_KEY',
19
+ bot_token='DISCORD_BOT_TOKEN')
20
+
21
+ router = CommandRouter()
22
+
23
+
24
+ @router.command('helloworld', 'say hello')
25
+ async def hello(ctx):
26
+ return 'hello'
27
+
28
+
29
+ @router.command('echo', 'echo a phrase back')
30
+ @option('text', 'the text to repeat')
31
+ async def echo(ctx, text: str):
32
+ return text
33
+
34
+ bot.attach_router(router)
35
+
36
+ bot.sync_commands() # Use only on build time if running on vercel
37
+ app = bot.app
38
+
39
+ ```
40
+ ---
41
+
42
+ > [!WARNING]
43
+ > ### SYNCING COMMANDS
44
+ > If you are on a serverless architecture like Vercel, make sure you only call `bot.sync_commands` during build time. You do not want this being executed every time you receive an interaction in production.
45
+
46
+ ---
@@ -0,0 +1,4 @@
1
+ from .bot import Bot
2
+ from .commands import CommandRouter, Option
3
+
4
+ __all__ = ["Bot", "CommandRouter", "Option"]
@@ -0,0 +1,314 @@
1
+ from fastapi import FastAPI, Request
2
+ from .middleware import VerifySignatureMiddleware
3
+ from .responses import InteractionResponse, PongResponse, MessageResponse
4
+ from .context import Context
5
+ from .models import (
6
+ InteractionType,
7
+ ApplicationCommandData,
8
+ Interaction,
9
+ Snowflake,
10
+ )
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
22
+
23
+
24
+ async def call_with_options(callback: callable, ctx: Context) -> Any:
25
+ """Invoke a command callback with option values bound to parameters.
26
+
27
+ Inspects the callback's signature and matches each parameter name to a registered
28
+ option in the interaction context. Values are passed as keyword arguments to the
29
+ callback. Parameters with default values are optional; missing required parameters
30
+ raise an error.
31
+
32
+ Args:
33
+ callback: The async command function to invoke.
34
+ ctx: The interaction context containing option values.
35
+
36
+ Returns:
37
+ The awaited result of the callback invocation.
38
+
39
+ Raises:
40
+ TypeError: If a required parameter has no matching option value in the context.
41
+ """
42
+ sig = inspect.signature(callback)
43
+ kwargs = {}
44
+
45
+ params = list(sig.parameters.values())[1:]
46
+ for param in params:
47
+ value = ctx.get_option_value(param.name)
48
+
49
+ if value is not None:
50
+ kwargs[param.name] = value
51
+ elif param.default is not inspect.Parameter.empty:
52
+ kwargs[param.name] = param.default
53
+ else:
54
+ raise TypeError(
55
+ f"Command {callback.__name__!r} has required parameter "
56
+ f"{param.name!r} but no matching option was provided"
57
+ )
58
+
59
+ return await callback(ctx, **kwargs)
60
+
61
+
62
+ class Bot:
63
+ def __init__(
64
+ self,
65
+ app_id: int,
66
+ public_key: str,
67
+ bot_token: str,
68
+ interactions_path: str = "/interactions",
69
+ ):
70
+ """Initialize a new Discord HTTP webhook bot.
71
+
72
+ Args:
73
+ app_id (int): The Discord Application ID.
74
+ public_key (str): Public key used to validate incoming webhook signatures
75
+ from Discord.
76
+ bot_token (str): Bot token used for authenticated API calls to Discord
77
+ (command registration, message sending, etc.).
78
+ interactions_path (str, optional): The route path where the bot listens
79
+ for interaction webhooks. Defaults to "/interactions".
80
+ """
81
+ self.app_id: int = app_id
82
+ self.public_key: str = public_key
83
+ self.bot_token: str = bot_token
84
+ self.interactions_path: str = interactions_path
85
+ self.base_url: str = f"https://discord.com/api/v10/applications/{app_id}"
86
+ self.commands: dict[str, Command] = {}
87
+
88
+ self.http = httpx.AsyncClient()
89
+
90
+ self.app = FastAPI(openapi_url=None)
91
+ self._register_routes()
92
+
93
+ async def process_interactions(self, request: Request):
94
+ payload = json.loads(request.state.raw_body)
95
+
96
+ if payload["type"] == InteractionType.PING:
97
+ return await PongResponse()
98
+
99
+ if payload["type"] == InteractionType.APPLICATION_COMMAND:
100
+ """Construct Context"""
101
+ try:
102
+ interaction = Interaction.model_validate(payload)
103
+ application_command = ApplicationCommandData.model_validate(
104
+ interaction.data
105
+ )
106
+ except (ValidationError, Exception):
107
+ # print(e.errors())
108
+ return await MessageResponse("Unexpected error", ephemeral=True)
109
+
110
+ context = Context(
111
+ interaction=interaction, options=application_command, http=self.http
112
+ )
113
+
114
+ return await self.dispatch(
115
+ command_name=application_command.name, ctx=context
116
+ )
117
+
118
+ return await MessageResponse("Unsupported interaction received", ephemeral=True)
119
+
120
+ def _register_routes(self) -> None:
121
+ """Set up middleware and the interactions endpoint.
122
+
123
+ Registers the signature verification middleware and configures the route handler
124
+ for the interactions endpoint. Called during initialization.
125
+ """
126
+ self.app.add_middleware(VerifySignatureMiddleware, public_key=self.public_key)
127
+ self.app.add_api_route(
128
+ self.interactions_path, endpoint=self.process_interactions, methods=["POST"]
129
+ )
130
+
131
+ def attach_router(self, router: CommandRouter) -> None:
132
+ """Attach a CommandRouter to the bot.
133
+
134
+ Registers a CommandRouter with this Discord webhook bot, enabling it to
135
+ handle slash commands, message components, modals, and other interactions
136
+ defined within the router.
137
+
138
+ Args:
139
+ router (CommandRouter): The router instance containing command
140
+ registrations and handler mappings.
141
+
142
+ Raises:
143
+ TypeError: If the provided router is not an instance of CommandRouter.
144
+ """
145
+ if not isinstance(router, CommandRouter):
146
+ raise TypeError(f"Expected a CommandRouter, got {type(router).__name__!r}")
147
+ self.commands.update(router.commands)
148
+ logger.info(
149
+ f"Router {router.name!r} attached with {len(router)} commands - {str(router)}"
150
+ )
151
+
152
+ def __load_routers_from_module(self, module: types.ModuleType) -> None:
153
+ """Discover and register routers from a module.
154
+
155
+ Checks for an explicit __routers__ list first. If not found, scans the module
156
+ for all CommandRouter instances and registers them automatically.
157
+
158
+ Args:
159
+ module: The module to scan for routers.
160
+
161
+ """
162
+ routers = getattr(module, "__routers__", None)
163
+
164
+ if routers is None:
165
+ routers = [
166
+ obj for obj in vars(module).values() if isinstance(obj, CommandRouter)
167
+ ]
168
+
169
+ if not routers:
170
+ logger.warning(f"No routers configured in {module.__name__!r}")
171
+ return
172
+
173
+ logger.debug(f"{len(routers)} routers discovered in {module.__name__!r}")
174
+ for router in routers:
175
+ self.attach_router(router)
176
+
177
+ def load_extension(self, path: str) -> None:
178
+ """Load extension(s) from a module or package.
179
+
180
+ Dynamically imports the given Python path and registers all `CommandRouter`
181
+ instances by calling the internal `__load_routers_from_module` method.
182
+
183
+ Behavior:
184
+ - If `path` points to a **module**: Loads routers from that single module.
185
+ - If `path` points to a **package**: Recursively discovers and loads
186
+ all non-package modules within it (and its subpackages).
187
+
188
+ Args:
189
+ path (str): Dot-separated import path to a module or package.
190
+
191
+ Raises:
192
+ ModuleNotFoundError: If the module or package does not exist.
193
+ ImportError: If an error occurs while importing any module.
194
+
195
+ Examples:
196
+ Load a single module:
197
+ ```python
198
+ bot.load_extension("my_bot.extensions.moderation")
199
+ ```
200
+ Load all modules from a package(recursive):
201
+ ```python
202
+ bot.load_extension('my_bot.extensions')
203
+ ```
204
+ """
205
+ module = importlib.import_module(path)
206
+ if hasattr(module, "__path__"):
207
+ # This is a package, lets recurisvely find modules
208
+ logger.info(f"Scanning packages {path!r} for extensions")
209
+ walked_packages = pkgutil.walk_packages(
210
+ module.__path__, module.__name__ + "."
211
+ )
212
+ for _, module_name, is_package in walked_packages:
213
+ if not is_package:
214
+ imported = importlib.import_module(module_name)
215
+ self.__load_routers_from_module(imported)
216
+ else:
217
+ self.__load_routers_from_module(module)
218
+
219
+ def sync_commands(self) -> None:
220
+ """Sync all registered commands with Discord's API.
221
+
222
+ Sends a bulk overwrite of the bot's slash commands using the Discord
223
+ Application Commands endpoint.
224
+
225
+ Raises:
226
+ Exception: If the API request fails.
227
+ """
228
+ global_payloads = []
229
+ guild_payloads: dict[int, list] = {}
230
+
231
+ for command in self.commands.values():
232
+ if command.guild_id is not None:
233
+ guild_payloads.setdefault(command.guild_id, []).append(
234
+ command.meta.as_payload()
235
+ )
236
+ else:
237
+ global_payloads.append(command.meta.as_payload())
238
+
239
+ if global_payloads:
240
+ self.__put__commands(f"{self.base_url}/commands", global_payloads)
241
+
242
+ for guild_id, payload in guild_payloads.items():
243
+ self.__put__commands(f"{self.base_url}/guilds/{guild_id}/commands", payload)
244
+
245
+ def __put__commands(self, url: str, payload: list) -> None:
246
+ """Send a batch of commands to Discord's API.
247
+
248
+ Makes a PUT request to the specified Discord endpoint with the command payload.
249
+ Handles both global and guild-scoped command registration.
250
+
251
+ Args:
252
+ url: The Discord API endpoint (global or guild-scoped).
253
+ payload: List of command definitions to register.
254
+
255
+ Raises:
256
+ Exception: If the HTTP response status is not 200.
257
+ """
258
+ headers = {"Authorization": f"Bot {self.bot_token}"}
259
+ with httpx.Client() as client:
260
+ response = client.put(url, headers=headers, json=payload)
261
+ if response.status_code != 200:
262
+ raise Exception(
263
+ {"error": "registering commands failed", "data": response.json()}
264
+ )
265
+ logger.info(f"Synced {len(payload)} commands to {url!r}")
266
+
267
+ async def dispatch(self, command_name: str, ctx: Context) -> InteractionResponse:
268
+ """Invoke a command and return its response.
269
+
270
+ Looks up the command by name and invokes its callback with the provided context.
271
+ Option values are automatically bound to the callback's parameters.
272
+
273
+ Args:
274
+ command_name (str): The name of the command to invoke.
275
+ ctx (Context): The interaction context.
276
+
277
+ Returns:
278
+ An InteractionResponse instance
279
+ """
280
+ command = self.commands.get(command_name)
281
+ if command is None:
282
+ return await MessageResponse("Unknown command", ephemeral=True)
283
+
284
+ result = await call_with_options(command.callback, ctx)
285
+
286
+ if not isinstance(result, InteractionResponse):
287
+ result = MessageResponse(str(result))
288
+
289
+ return await result
290
+
291
+ def delete_all_commands(self, guild_id: Snowflake = None) -> None:
292
+ """
293
+ Delete all application commands by replacing the command set with an empty list.
294
+
295
+ This performs a bulk overwrite operation. If ``guild_id`` is not
296
+ provided, all global commands are deleted. Otherwise, all commands
297
+ registered for the specified guild are deleted.
298
+
299
+ Parameters
300
+ ----------
301
+ guild_id : Snowflake, optional
302
+ The guild ID to target. If ``None``, the global command scope
303
+ is used.
304
+
305
+ Returns
306
+ -------
307
+ None
308
+ """
309
+ if not guild_id:
310
+ url = f"{self.base_url}/commands"
311
+ else:
312
+ url = f"{self.base_url}/guilds/{guild_id}/commands"
313
+
314
+ self.__put__commands(url, [])
@@ -0,0 +1,366 @@
1
+ from dataclasses import dataclass, field
2
+ from .models import Snowflake, ApplicationCommandOptionType
3
+ from typing import Optional
4
+ import sys
5
+
6
+ OptionType = ApplicationCommandOptionType
7
+
8
+
9
+ @dataclass
10
+ class CommandOption:
11
+ name: str
12
+ description: str
13
+ type: OptionType = OptionType.STRING
14
+ required: bool = True
15
+
16
+ def as_payload(self):
17
+ return {
18
+ "name": self.name,
19
+ "description": self.description,
20
+ "type": self.type,
21
+ "required": self.required,
22
+ }
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class CommandMeta:
27
+ name: str
28
+ description: str
29
+ options: list[CommandOption] = field(default_factory=list)
30
+ type: int = 1
31
+
32
+ def as_payload(self):
33
+ return {
34
+ "name": self.name,
35
+ "description": self.description,
36
+ "type": self.type,
37
+ "options": [option.as_payload() for option in self.options],
38
+ }
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class Command:
43
+ callback: callable
44
+ meta: CommandMeta
45
+ guild_id: Optional[Snowflake] = None
46
+
47
+
48
+ class CommandRouter:
49
+ def __init__(self, name: str = None, guild_id: Optional[Snowflake] = None):
50
+ self.name = name or self.__infer_name()
51
+ self.guild_id: Optional[Snowflake] = guild_id
52
+ self.commands: dict[str, Command] = {}
53
+
54
+ def __infer_name(self) -> str:
55
+ frame = sys._getframe(2)
56
+ return frame.f_globals.get("__name__", "Unknown")
57
+
58
+ def __len__(self) -> int:
59
+ return len(list(self.commands.keys()))
60
+
61
+ def __str__(self) -> str:
62
+ return f"{list(self.commands.keys())}"
63
+
64
+ def command(self, name: str, description: str) -> None:
65
+ """
66
+ Add a slash command to this router.
67
+
68
+ The decorated function is registered as the handler for the command
69
+ and will be included when the router is attached to the application.
70
+
71
+ Args:
72
+ name: Unique command name.
73
+ description: User-facing command description.
74
+
75
+ Example:
76
+ ```python
77
+ @router.command("hello", "Say hello")
78
+ async def hello(ctx):
79
+ return "Hello!"
80
+ ```
81
+ """
82
+
83
+ def decorator(func):
84
+ meta = getattr(
85
+ func, "_command_meta", CommandMeta(name="", description="", type=1)
86
+ )
87
+ meta.name = name
88
+ meta.description = description
89
+ self.commands[name] = Command(
90
+ callback=func, meta=meta, guild_id=self.guild_id
91
+ )
92
+ func.__dict__.pop("_command_meta", None)
93
+ return func
94
+
95
+ return decorator
96
+
97
+
98
+ class Option:
99
+ @staticmethod
100
+ def __create_operation_decorator(name: str, description: str, option_type: OptionType, required: bool = True):
101
+ def decorator(func):
102
+ meta = getattr(
103
+ func, '_command_meta', CommandMeta(name='', description='', type=1)
104
+ )
105
+
106
+ meta.options.insert(
107
+ 0,
108
+ CommandOption(
109
+ name=name,
110
+ description=description,
111
+ type=option_type,
112
+ required=required
113
+ )
114
+ )
115
+ func._command_meta = meta
116
+ return func
117
+ return decorator
118
+
119
+ @staticmethod
120
+ def string(name: str, description: str, required: bool = True):
121
+ """Register a string option on a command.
122
+
123
+ Decorates a command callback to add a string option parameter. The option name
124
+ must match a parameter name in the callback for automatic value binding.
125
+
126
+ Args:
127
+ name: The option name. Must match a callback parameter name.
128
+ description: Human-readable description shown to Discord users.
129
+ required: Whether the option is required. Defaults to True.
130
+
131
+ Returns:
132
+ A decorator that attaches the option metadata to the function.
133
+
134
+ Example:
135
+ ```python
136
+ @router.command(name="echo", description="Echo text")
137
+ @Option.string(name="text", description="Text to echo", required=True)
138
+ async def echo(ctx, text: str):
139
+ return text
140
+ ```
141
+ """
142
+ return Option.__create_operation_decorator(
143
+ name=name,
144
+ description=description,
145
+ option_type=OptionType.STRING,
146
+ required=required
147
+ )
148
+
149
+ @staticmethod
150
+ def integer(name: str, description: str, required: bool = True):
151
+ """Register an integer option on a command.
152
+
153
+ Decorates a command callback to add an integer option parameter. The option name
154
+ must match a parameter name in the callback for automatic value binding.
155
+
156
+ Args:
157
+ name: The option name. Must match a callback parameter name.
158
+ description: Human-readable description shown to Discord users.
159
+ required: Whether the option is required. Defaults to True.
160
+
161
+ Returns:
162
+ A decorator that attaches the option metadata to the function.
163
+
164
+ Example:
165
+ ```python
166
+ @router.command(name="echo", description="Echo integer")
167
+ @Option.integer(name="number", description="Integer to echo", required=True)
168
+ async def echo(ctx, number: int):
169
+ return int
170
+ ```
171
+ """
172
+ return Option.__create_operation_decorator(
173
+ name=name,
174
+ description=description,
175
+ option_type=OptionType.INTEGER,
176
+ required=required
177
+ )
178
+
179
+ @staticmethod
180
+ def user(name: str, description: str, required: bool = True):
181
+ """Register a user option on a command.
182
+
183
+ Decorates a command callback to add a user option parameter. The option name
184
+ must match a parameter name in the callback for automatic value binding.
185
+
186
+ Args:
187
+ name: The option name. Must match a callback parameter name.
188
+ description: Human-readable description shown to Discord users.
189
+ required: Whether the option is required. Defaults to True.
190
+
191
+ Returns:
192
+ A decorator that attaches the option metadata to the function.
193
+
194
+ Example:
195
+ ```python
196
+ @router.command(name="kick", description="Kick a user")
197
+ @Option.user(name="user", description="Target user to kick", required=True)
198
+ async def kick(ctx, user: Snowflake):
199
+ ...
200
+ return 'User kicked'
201
+ ```
202
+ """
203
+ return Option.__create_operation_decorator(
204
+ name=name,
205
+ description=description,
206
+ option_type=OptionType.USER,
207
+ required=required
208
+ )
209
+
210
+ @staticmethod
211
+ def channel(name: str, description: str, required: bool = True):
212
+ """Register a channel option on a command.
213
+
214
+ Decorates a command callback to add a channel option parameter. The option name
215
+ must match a parameter name in the callback for automatic value binding.
216
+
217
+ Args:
218
+ name: The option name. Must match a callback parameter name.
219
+ description: Human-readable description shown to Discord users.
220
+ required: Whether the option is required. Defaults to True.
221
+
222
+ Returns:
223
+ A decorator that attaches the option metadata to the function.
224
+
225
+ Example:
226
+ ```python
227
+ @router.command(name="purge", description="Purge a channel")
228
+ @Option.user(name="channel", description="Target channel to purge", required=True)
229
+ async def purge(ctx, channel: Snowflake):
230
+ ...
231
+ return 'Channel purged'
232
+ ```
233
+ """
234
+ return Option.__create_operation_decorator(
235
+ name=name,
236
+ description=description,
237
+ option_type=OptionType.CHANNEL,
238
+ required=required
239
+ )
240
+
241
+ @staticmethod
242
+ def role(name: str, description: str, required: bool = True):
243
+ """Register a role option on a command.
244
+
245
+ Decorates a command callback to add a channel option parameter. The option name
246
+ must match a parameter name in the callback for automatic value binding.
247
+
248
+ Args:
249
+ name: The option name. Must match a callback parameter name.
250
+ description: Human-readable description shown to Discord users.
251
+ required: Whether the option is required. Defaults to True.
252
+
253
+ Returns:
254
+ A decorator that attaches the option metadata to the function.
255
+
256
+ Example:
257
+ ```python
258
+ @router.command(name="rmrole", description="Delete a role")
259
+ @Option.role(name="role", description="role to delete", required=True)
260
+ async def purge(ctx, role: Snowflake):
261
+ ...
262
+ return 'Role purged'
263
+ ```
264
+ """
265
+ return Option.__create_operation_decorator(
266
+ name=name,
267
+ description=description,
268
+ option_type=OptionType.ROLE,
269
+ required=required
270
+ )
271
+
272
+ @staticmethod
273
+ def mentionable(name: str, description: str, required: bool = True):
274
+ """Register a mentionable option on a command.
275
+
276
+ Decorates a command callback to add a channel option parameter. The option name
277
+ must match a parameter name in the callback for automatic value binding.
278
+
279
+ Args:
280
+ name: The option name. Must match a callback parameter name.
281
+ description: Human-readable description shown to Discord users.
282
+ required: Whether the option is required. Defaults to True.
283
+
284
+ Returns:
285
+ A decorator that attaches the option metadata to the function.
286
+
287
+ Example:
288
+ ```python
289
+ @router.command(name="warn", description="Warn a user or role")
290
+ @Option.mentionable(name="target", description="User or role to warn", required=True)
291
+ async def warn(ctx, target: Snowflake):
292
+ return f"⚠️ Warning issued to <@&{target}>"
293
+ ```
294
+ """
295
+ return Option.__create_operation_decorator(
296
+ name=name,
297
+ description=description,
298
+ option_type=OptionType.MENTIONABLE,
299
+ required=required
300
+ )
301
+
302
+ @staticmethod
303
+ def boolean(name: str, description: str, required: bool = True):
304
+ """Register a boolean option on a command.
305
+
306
+ Decorates a command callback to add a boolean option parameter. The option name
307
+ must match a parameter name in the callback for automatic value binding.
308
+
309
+ Args:
310
+ name: The option name. Must match a callback parameter name.
311
+ description: Human-readable description shown to Discord users.
312
+ required: Whether the option is required. Defaults to True.
313
+
314
+ Returns:
315
+ A decorator that attaches the option metadata to the function.
316
+
317
+ Example:
318
+ ```python
319
+ @router.command(name="ban", description="Ban a user")
320
+ @Option.user(name="target", description="User to ban", required=True)
321
+ @Option.boolean(name="soft", description="Is this a softban", required=True)
322
+ async def warn(ctx, target: Snowflake, soft: bool):
323
+ if soft:
324
+ ...
325
+ else:
326
+ ...
327
+ return 'Command executed'
328
+ ```
329
+ """
330
+ return Option.__create_operation_decorator(
331
+ name=name,
332
+ description=description,
333
+ option_type=OptionType.BOOLEAN,
334
+ required=required
335
+ )
336
+
337
+ @staticmethod
338
+ def number(name: str, description: str, required: bool = True):
339
+ """Register a number option on a command.
340
+
341
+ Decorates a command callback to add a number option parameter. The option name
342
+ must match a parameter name in the callback for automatic value binding.
343
+ This is a float in python.
344
+
345
+ Args:
346
+ name: The option name. Must match a callback parameter name.
347
+ description: Human-readable description shown to Discord users.
348
+ required: Whether the option is required. Defaults to True.
349
+
350
+ Returns:
351
+ A decorator that attaches the option metadata to the function.
352
+
353
+ Example:
354
+ ```python
355
+ @router.command(name="rate", description="Rate something")
356
+ @Option.number(name="score", description="Rating from 0 to 10", required=True)
357
+ async def rate(ctx, score: float):
358
+ return f"Rating: {score}/10 ⭐"
359
+ ```
360
+ """
361
+ return Option.__create_operation_decorator(
362
+ name=name,
363
+ description=description,
364
+ option_type=OptionType.NUMBER,
365
+ required=required
366
+ )
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,46 @@
1
+ from fastapi import Request
2
+ from fastapi.responses import JSONResponse
3
+ from starlette.middleware.base import BaseHTTPMiddleware
4
+ from nacl.signing import VerifyKey
5
+ from nacl.exceptions import BadSignatureError
6
+
7
+
8
+ def verify_signature(
9
+ public_key: str, signature: str, timestamp: str, body: bytes
10
+ ) -> bool:
11
+ try:
12
+ VerifyKey(bytes.fromhex(public_key)).verify(
13
+ timestamp.encode() + body, bytes.fromhex(signature)
14
+ )
15
+ return True
16
+ except (BadSignatureError, ValueError):
17
+ return False
18
+
19
+
20
+ class VerifySignatureMiddleware(BaseHTTPMiddleware):
21
+ def __init__(self, app, public_key: str, interaction_path: str = "/interactions"):
22
+ super().__init__(app)
23
+ self.public_key = public_key
24
+ self.interaction_path = interaction_path
25
+
26
+ async def dispatch(self, request: Request, call_next):
27
+ if request.url.path != self.interaction_path:
28
+ return await call_next(request)
29
+
30
+ signature = request.headers["x-signature-ed25519"]
31
+ timestamp = request.headers["x-signature-timestamp"]
32
+ if not signature or not timestamp:
33
+ return JSONResponse({"detail": "Missing headers"}, status_code=401)
34
+
35
+ body = await request.body()
36
+ verified_signature = verify_signature(
37
+ public_key=self.public_key,
38
+ signature=signature,
39
+ timestamp=timestamp,
40
+ body=body,
41
+ )
42
+ if not verified_signature:
43
+ return JSONResponse({"detail": "Invalid signature"}, status_code=401)
44
+ else:
45
+ request.state.raw_body = body
46
+ return await call_next(request)
@@ -0,0 +1,146 @@
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
@@ -0,0 +1,61 @@
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()
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-interactions
3
+ Version: 0.0.1
4
+ Summary: A lightweight framework for discord interactions over http built on FastAPI
5
+ Author-email: Haider Ali <haideralidevnull@gmail.com>
6
+ Requires-Python: >=3.13
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: fastapi[standard]>=0.138.0
9
+ Requires-Dist: pydantic>=2.13.4
10
+ Requires-Dist: pynacl>=1.6.2
11
+ Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: loguru>=0.7.3
13
+ Provides-Extra: docs
14
+ Requires-Dist: mkdocs; extra == "docs"
15
+ Requires-Dist: mkdocstrings[python]; extra == "docs"
16
+ Requires-Dist: mkdocs-material; extra == "docs"
17
+
18
+ <a href='https://fastapi-interactions.readthedocs.io/en/latest/' target='_blank'>Read the docs here</a>
19
+
20
+ # Install fastapi-interactions
21
+
22
+ ```
23
+ pip install fastapi-interactions
24
+ ```
25
+
26
+ # Quick example
27
+
28
+ ```python
29
+ from fastapi_interactions import Bot
30
+ from fastapi_interactions.commands import (
31
+ CommandRouter, option,
32
+ )
33
+
34
+ bot = Bot(app_id='DISCORD_APP_ID',
35
+ public_key='DISCORD_PUBLIC_KEY',
36
+ bot_token='DISCORD_BOT_TOKEN')
37
+
38
+ router = CommandRouter()
39
+
40
+
41
+ @router.command('helloworld', 'say hello')
42
+ async def hello(ctx):
43
+ return 'hello'
44
+
45
+
46
+ @router.command('echo', 'echo a phrase back')
47
+ @option('text', 'the text to repeat')
48
+ async def echo(ctx, text: str):
49
+ return text
50
+
51
+ bot.attach_router(router)
52
+
53
+ bot.sync_commands() # Use only on build time if running on vercel
54
+ app = bot.app
55
+
56
+ ```
57
+ ---
58
+
59
+ > [!WARNING]
60
+ > ### SYNCING COMMANDS
61
+ > If you are on a serverless architecture like Vercel, make sure you only call `bot.sync_commands` during build time. You do not want this being executed every time you receive an interaction in production.
62
+
63
+ ---
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ fastapi_interactions/__init__.py
4
+ fastapi_interactions/bot.py
5
+ fastapi_interactions/commands.py
6
+ fastapi_interactions/context.py
7
+ fastapi_interactions/middleware.py
8
+ fastapi_interactions/models.py
9
+ fastapi_interactions/responses.py
10
+ fastapi_interactions.egg-info/PKG-INFO
11
+ fastapi_interactions.egg-info/SOURCES.txt
12
+ fastapi_interactions.egg-info/dependency_links.txt
13
+ fastapi_interactions.egg-info/requires.txt
14
+ fastapi_interactions.egg-info/top_level.txt
@@ -0,0 +1,10 @@
1
+ fastapi[standard]>=0.138.0
2
+ pydantic>=2.13.4
3
+ pynacl>=1.6.2
4
+ httpx>=0.28.1
5
+ loguru>=0.7.3
6
+
7
+ [docs]
8
+ mkdocs
9
+ mkdocstrings[python]
10
+ mkdocs-material
@@ -0,0 +1 @@
1
+ fastapi_interactions
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ['setuptools>=82.0.1']
3
+ build-backend = 'setuptools.build_meta'
4
+
5
+ [project]
6
+ name = 'fastapi-interactions'
7
+ version = '0.0.1'
8
+ description='A lightweight framework for discord interactions over http built on FastAPI'
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ 'fastapi[standard]>=0.138.0',
13
+ 'pydantic>=2.13.4',
14
+ 'pynacl>=1.6.2',
15
+ 'httpx>=0.28.1',
16
+ 'loguru>=0.7.3'
17
+ ]
18
+ authors = [
19
+ { name = "Haider Ali", email = "haideralidevnull@gmail.com" }
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ docs = [
24
+ "mkdocs",
25
+ "mkdocstrings[python]",
26
+ "mkdocs-material"
27
+ ]
28
+
29
+ [tool.setuptools.packages.find]
30
+ include = ['fastapi_interactions*']
31
+ exclude = ['docs*', 'exts*', 'tests*']
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+