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.
- fastapi_interactions-0.0.1/PKG-INFO +63 -0
- fastapi_interactions-0.0.1/README.md +46 -0
- fastapi_interactions-0.0.1/fastapi_interactions/__init__.py +4 -0
- fastapi_interactions-0.0.1/fastapi_interactions/bot.py +314 -0
- fastapi_interactions-0.0.1/fastapi_interactions/commands.py +366 -0
- fastapi_interactions-0.0.1/fastapi_interactions/context.py +43 -0
- fastapi_interactions-0.0.1/fastapi_interactions/middleware.py +46 -0
- fastapi_interactions-0.0.1/fastapi_interactions/models.py +146 -0
- fastapi_interactions-0.0.1/fastapi_interactions/responses.py +61 -0
- fastapi_interactions-0.0.1/fastapi_interactions.egg-info/PKG-INFO +63 -0
- fastapi_interactions-0.0.1/fastapi_interactions.egg-info/SOURCES.txt +14 -0
- fastapi_interactions-0.0.1/fastapi_interactions.egg-info/dependency_links.txt +1 -0
- fastapi_interactions-0.0.1/fastapi_interactions.egg-info/requires.txt +10 -0
- fastapi_interactions-0.0.1/fastapi_interactions.egg-info/top_level.txt +1 -0
- fastapi_interactions-0.0.1/pyproject.toml +31 -0
- fastapi_interactions-0.0.1/setup.cfg +4 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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*']
|