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.
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/PKG-INFO +1 -1
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions/__init__.py +2 -0
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions/bot.py +63 -23
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions/commands.py +110 -28
- fastapi_interactions-0.1.1/fastapi_interactions/context.py +83 -0
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions/middleware.py +25 -0
- fastapi_interactions-0.1.1/fastapi_interactions/models.py +286 -0
- fastapi_interactions-0.1.1/fastapi_interactions/responses.py +104 -0
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/PKG-INFO +1 -1
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/pyproject.toml +2 -2
- fastapi_interactions-0.0.1/fastapi_interactions/context.py +0 -43
- fastapi_interactions-0.0.1/fastapi_interactions/models.py +0 -146
- fastapi_interactions-0.0.1/fastapi_interactions/responses.py +0 -61
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/README.md +0 -0
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/SOURCES.txt +0 -0
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/dependency_links.txt +0 -0
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/requires.txt +0 -0
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/top_level.txt +0 -0
- {fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/setup.cfg +0 -0
|
@@ -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 .
|
|
3
|
-
from
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
312
|
+
return _JSONResponse(MessageResponse("Unknown command", ephemeral=True))
|
|
283
313
|
|
|
284
314
|
result = await call_with_options(command.callback, ctx)
|
|
285
315
|
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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:
|
|
75
|
+
guild_id: Snowflake | None = None
|
|
46
76
|
|
|
47
77
|
|
|
48
78
|
class CommandRouter:
|
|
49
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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
|
{fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions/middleware.py
RENAMED
|
@@ -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)}}
|
|
@@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = 'fastapi-interactions'
|
|
7
|
-
version = '0.
|
|
7
|
+
version = '0.1.1'
|
|
8
8
|
description='A lightweight framework for discord interactions over http built on FastAPI'
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -28,4 +28,4 @@ docs = [
|
|
|
28
28
|
|
|
29
29
|
[tool.setuptools.packages.find]
|
|
30
30
|
include = ['fastapi_interactions*']
|
|
31
|
-
exclude = ['docs*', 'exts*', 'tests*']
|
|
31
|
+
exclude = ['docs*', 'exts*', 'tests*']
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
2
|
-
from .models import Interaction, ApplicationCommandData, User, Snowflake
|
|
3
|
-
from typing import Optional, Any
|
|
4
|
-
import httpx
|
|
5
|
-
from .responses import MessageResponse
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@dataclass
|
|
9
|
-
class Context:
|
|
10
|
-
interaction: Interaction
|
|
11
|
-
options: ApplicationCommandData
|
|
12
|
-
http: httpx.AsyncClient
|
|
13
|
-
|
|
14
|
-
@property
|
|
15
|
-
def user(self) -> User:
|
|
16
|
-
if self.interaction.user is not None:
|
|
17
|
-
return self.interaction.user
|
|
18
|
-
if (
|
|
19
|
-
self.interaction.member is not None
|
|
20
|
-
and self.interaction.member.user is not None
|
|
21
|
-
):
|
|
22
|
-
return self.interaction.member.user
|
|
23
|
-
raise ValueError("Interaction does not contain `user` or `member.user`")
|
|
24
|
-
|
|
25
|
-
@property
|
|
26
|
-
def guild_id(self) -> Optional[Snowflake]:
|
|
27
|
-
return self.interaction.guild_id
|
|
28
|
-
|
|
29
|
-
@property
|
|
30
|
-
def channel_id(self) -> Optional[Snowflake]:
|
|
31
|
-
return self.interaction.channel_id
|
|
32
|
-
|
|
33
|
-
@property
|
|
34
|
-
def _webhook_base(self) -> str:
|
|
35
|
-
return f"https://discord.com/api/v10/webhooks/{self.interaction.application_id}/{self.interaction.token}"
|
|
36
|
-
|
|
37
|
-
async def send(self, content: str, ephemeral: bool = False) -> None:
|
|
38
|
-
message = MessageResponse(content, ephemeral=ephemeral)
|
|
39
|
-
await self.http.post(url=self._webhook_base, json=message.to_payload())
|
|
40
|
-
|
|
41
|
-
def get_option_value(self, name: str, default: Any = None) -> Any:
|
|
42
|
-
option = self.options.options_by_name.get(name)
|
|
43
|
-
return option.value if option is not None else default
|
|
@@ -1,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()
|
|
File without changes
|
{fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_interactions-0.0.1 → fastapi_interactions-0.1.1}/fastapi_interactions.egg-info/requires.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|