scurrypy 0.4__py3-none-any.whl → 0.6.6__py3-none-any.whl
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.
- scurrypy/__init__.py +429 -0
- scurrypy/client.py +335 -0
- {discord → scurrypy}/client_like.py +8 -1
- scurrypy/dispatch/command_dispatcher.py +205 -0
- {discord → scurrypy}/dispatch/event_dispatcher.py +21 -21
- {discord → scurrypy}/dispatch/prefix_dispatcher.py +31 -12
- {discord → scurrypy}/error.py +6 -18
- {discord → scurrypy}/events/channel_events.py +2 -1
- scurrypy/events/gateway_events.py +31 -0
- {discord → scurrypy}/events/guild_events.py +2 -1
- {discord → scurrypy}/events/interaction_events.py +28 -13
- {discord → scurrypy}/events/message_events.py +8 -5
- {discord → scurrypy}/events/reaction_events.py +1 -2
- {discord → scurrypy}/events/ready_event.py +1 -3
- scurrypy/gateway.py +183 -0
- scurrypy/http.py +310 -0
- {discord → scurrypy}/intents.py +5 -7
- {discord → scurrypy}/logger.py +14 -61
- scurrypy/model.py +71 -0
- scurrypy/models.py +258 -0
- scurrypy/parts/channel.py +42 -0
- scurrypy/parts/command.py +90 -0
- scurrypy/parts/components.py +224 -0
- scurrypy/parts/components_v2.py +144 -0
- scurrypy/parts/embed.py +83 -0
- scurrypy/parts/message.py +134 -0
- scurrypy/parts/modal.py +16 -0
- {discord → scurrypy}/parts/role.py +2 -14
- {discord → scurrypy}/resources/application.py +1 -2
- {discord → scurrypy}/resources/bot_emojis.py +1 -1
- {discord → scurrypy}/resources/channel.py +9 -8
- {discord → scurrypy}/resources/guild.py +14 -16
- {discord → scurrypy}/resources/interaction.py +50 -43
- {discord → scurrypy}/resources/message.py +15 -16
- {discord → scurrypy}/resources/user.py +3 -4
- scurrypy-0.6.6.dist-info/METADATA +108 -0
- scurrypy-0.6.6.dist-info/RECORD +47 -0
- {scurrypy-0.4.dist-info → scurrypy-0.6.6.dist-info}/licenses/LICENSE +1 -1
- scurrypy-0.6.6.dist-info/top_level.txt +1 -0
- discord/__init__.py +0 -223
- discord/client.py +0 -375
- discord/dispatch/command_dispatcher.py +0 -163
- discord/gateway.py +0 -155
- discord/http.py +0 -280
- discord/model.py +0 -90
- discord/models/__init__.py +0 -1
- discord/models/application.py +0 -37
- discord/models/emoji.py +0 -34
- discord/models/guild.py +0 -35
- discord/models/integration.py +0 -23
- discord/models/interaction.py +0 -26
- discord/models/member.py +0 -27
- discord/models/role.py +0 -53
- discord/models/user.py +0 -15
- discord/parts/action_row.py +0 -208
- discord/parts/channel.py +0 -20
- discord/parts/command.py +0 -102
- discord/parts/components_v2.py +0 -353
- discord/parts/embed.py +0 -154
- discord/parts/message.py +0 -194
- discord/parts/modal.py +0 -21
- scurrypy-0.4.dist-info/METADATA +0 -130
- scurrypy-0.4.dist-info/RECORD +0 -54
- scurrypy-0.4.dist-info/top_level.txt +0 -1
- {discord → scurrypy}/config.py +0 -0
- {discord → scurrypy}/dispatch/__init__.py +0 -0
- {discord → scurrypy}/events/__init__.py +0 -0
- {discord → scurrypy}/events/hello_event.py +0 -0
- {discord → scurrypy}/parts/__init__.py +0 -0
- {discord → scurrypy}/parts/component_types.py +0 -0
- {discord → scurrypy}/resources/__init__.py +0 -0
- {scurrypy-0.4.dist-info → scurrypy-0.6.6.dist-info}/WHEEL +0 -0
scurrypy/client.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from .config import BaseConfig
|
|
4
|
+
from .intents import Intents
|
|
5
|
+
from .gateway import GatewayClient
|
|
6
|
+
from .client_like import ClientLike
|
|
7
|
+
|
|
8
|
+
from .parts.command import SlashCommand, MessageCommand, UserCommand
|
|
9
|
+
|
|
10
|
+
class Client(ClientLike):
|
|
11
|
+
"""Main entry point for Discord bots.
|
|
12
|
+
Ties together the moving parts: gateway, HTTP, event dispatching, command handling, and resource managers.
|
|
13
|
+
"""
|
|
14
|
+
def __init__(self,
|
|
15
|
+
*,
|
|
16
|
+
token: str,
|
|
17
|
+
application_id: int,
|
|
18
|
+
intents: int = Intents.DEFAULT,
|
|
19
|
+
config: BaseConfig = None,
|
|
20
|
+
debug_mode: bool = False,
|
|
21
|
+
sync_commands: bool = True,
|
|
22
|
+
prefix = None,
|
|
23
|
+
quiet: bool = False
|
|
24
|
+
):
|
|
25
|
+
"""
|
|
26
|
+
Args:
|
|
27
|
+
token (str): the bot's token
|
|
28
|
+
application_id (int): the bot's user ID
|
|
29
|
+
intents (int, optional): gateway intents. Defaults to Intents.DEFAULT.
|
|
30
|
+
config (BaseConfig, optional): user-defined config data
|
|
31
|
+
sync_commands (bool, optional): toggle registering commands. Defaults to True.
|
|
32
|
+
debug_mode (bool, optional): toggle debug messages. Defaults to False.
|
|
33
|
+
prefix (str, optional): set message prefix if using command prefixes
|
|
34
|
+
quiet (bool, optional): if INFO, DEBUG, and WARN should be logged
|
|
35
|
+
"""
|
|
36
|
+
if not token:
|
|
37
|
+
raise ValueError("Token is required")
|
|
38
|
+
if not application_id:
|
|
39
|
+
raise ValueError("Application ID is required")
|
|
40
|
+
|
|
41
|
+
from .logger import Logger
|
|
42
|
+
from .http import HTTPClient
|
|
43
|
+
from .resources.bot_emojis import BotEmojis
|
|
44
|
+
from .dispatch.event_dispatcher import EventDispatcher
|
|
45
|
+
from .dispatch.prefix_dispatcher import PrefixDispatcher
|
|
46
|
+
from .dispatch.command_dispatcher import CommandDispatcher
|
|
47
|
+
|
|
48
|
+
self.token = token
|
|
49
|
+
self.intents = intents
|
|
50
|
+
self.application_id = application_id
|
|
51
|
+
self.config = config
|
|
52
|
+
self.sync_commands = sync_commands
|
|
53
|
+
|
|
54
|
+
self._logger = Logger(debug_mode, quiet)
|
|
55
|
+
|
|
56
|
+
self._http = HTTPClient(self._logger)
|
|
57
|
+
|
|
58
|
+
self.shards: list[GatewayClient] = []
|
|
59
|
+
self.dispatcher = EventDispatcher(self)
|
|
60
|
+
self.prefix_dispatcher = PrefixDispatcher(self, prefix)
|
|
61
|
+
self.command_dispatcher = CommandDispatcher(self)
|
|
62
|
+
|
|
63
|
+
self._setup_hooks = []
|
|
64
|
+
self._shutdown_hooks = []
|
|
65
|
+
|
|
66
|
+
self.emojis = BotEmojis(self._http, self.application_id)
|
|
67
|
+
|
|
68
|
+
def prefix_command(self, name: str):
|
|
69
|
+
"""Decorator registers prefix commands by the name of the function.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
name (str): name of the command
|
|
73
|
+
!!! warning "Important"
|
|
74
|
+
Prefix commands are CASE-INSENSITIVE.
|
|
75
|
+
"""
|
|
76
|
+
def decorator(func):
|
|
77
|
+
self.prefix_dispatcher.register(name.lower(), func)
|
|
78
|
+
return func
|
|
79
|
+
return decorator
|
|
80
|
+
|
|
81
|
+
def component(self, custom_id: str):
|
|
82
|
+
"""Decorator registers a function for a component handler.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
custom_id (str): Identifier of the component. Must match the `custom_id` set where the component was created.
|
|
86
|
+
"""
|
|
87
|
+
def decorator(func):
|
|
88
|
+
self.command_dispatcher.component(func, custom_id)
|
|
89
|
+
return func
|
|
90
|
+
return decorator
|
|
91
|
+
|
|
92
|
+
def command(self, command: SlashCommand | MessageCommand | UserCommand, guild_ids: list[int] = None):
|
|
93
|
+
"""Decorator registers a function to a command handler.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
command (SlashCommand | MessageCommand | UserCommand): the command object
|
|
97
|
+
guild_ids (list[int], optional): Guild IDs to register command to (if any). If omitted, the command is **global**.
|
|
98
|
+
"""
|
|
99
|
+
def decorator(func):
|
|
100
|
+
if not isinstance(command, (SlashCommand, MessageCommand, UserCommand)):
|
|
101
|
+
raise ValueError(f"Expected SlashCommand, MessageCommand, or UserCommand; got {type(command).__name__}")
|
|
102
|
+
|
|
103
|
+
# maps command type -> command registry
|
|
104
|
+
handler_map = {
|
|
105
|
+
SlashCommand: self.command_dispatcher.add_slash_command,
|
|
106
|
+
MessageCommand: self.command_dispatcher.add_message_command,
|
|
107
|
+
UserCommand: self.command_dispatcher.add_user_command
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# can guarantee at this point command is one of SlashCommand | MessageCommand | UserCommand
|
|
111
|
+
handler = handler_map[type(command)]
|
|
112
|
+
|
|
113
|
+
handler(command, func, guild_ids)
|
|
114
|
+
return func
|
|
115
|
+
return decorator
|
|
116
|
+
|
|
117
|
+
def event(self, event_name: str):
|
|
118
|
+
"""Decorator registers a function for an event handler.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
event_name (str): event name (must be a valid event)
|
|
122
|
+
"""
|
|
123
|
+
def decorator(func):
|
|
124
|
+
self.dispatcher.register(event_name, func)
|
|
125
|
+
return func
|
|
126
|
+
return decorator
|
|
127
|
+
|
|
128
|
+
def setup_hook(self, func):
|
|
129
|
+
"""Decorator registers a setup hook.
|
|
130
|
+
(Runs once before the bot starts listening)
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
func (callable): callback to the setup function
|
|
134
|
+
"""
|
|
135
|
+
self._setup_hooks.append(func)
|
|
136
|
+
|
|
137
|
+
def shutdown_hook(self, func):
|
|
138
|
+
"""Decorator registers a shutdown hook.
|
|
139
|
+
(Runs once before the bot exits the loop)
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
func (callable): callback to the shutdown function
|
|
143
|
+
"""
|
|
144
|
+
self._shutdown_hooks.append(func)
|
|
145
|
+
|
|
146
|
+
def fetch_application(self, application_id: int):
|
|
147
|
+
"""Creates an interactable application resource.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
application_id (int): ID of target application
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
(Application): the Application resource
|
|
154
|
+
"""
|
|
155
|
+
from .resources.application import Application
|
|
156
|
+
|
|
157
|
+
return Application(application_id, self._http)
|
|
158
|
+
|
|
159
|
+
def fetch_guild(self, guild_id: int):
|
|
160
|
+
"""Creates an interactable guild resource.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
guild_id (int): ID of target guild
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
(Guild): the Guild resource
|
|
167
|
+
"""
|
|
168
|
+
from .resources.guild import Guild
|
|
169
|
+
|
|
170
|
+
return Guild(guild_id, self._http)
|
|
171
|
+
|
|
172
|
+
def fetch_channel(self, channel_id: int):
|
|
173
|
+
"""Creates an interactable channel resource.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
channel_id (int): ID of target channel
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
(Channel): the Channel resource
|
|
180
|
+
"""
|
|
181
|
+
from .resources.channel import Channel
|
|
182
|
+
|
|
183
|
+
return Channel(channel_id, self._http)
|
|
184
|
+
|
|
185
|
+
def fetch_message(self, channel_id: int, message_id: int):
|
|
186
|
+
"""Creates an interactable message resource.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
message_id (int): ID of target message
|
|
190
|
+
channel_id (int): channel ID of target message
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
(Message): the Message resource
|
|
194
|
+
"""
|
|
195
|
+
from .resources.message import Message
|
|
196
|
+
|
|
197
|
+
return Message(message_id, channel_id, self._http)
|
|
198
|
+
|
|
199
|
+
def fetch_user(self, user_id: int):
|
|
200
|
+
"""Creates an interactable user resource.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
user_id (int): ID of target user
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
(User): the User resource
|
|
207
|
+
"""
|
|
208
|
+
from .resources.user import User
|
|
209
|
+
|
|
210
|
+
return User(user_id, self._http)
|
|
211
|
+
|
|
212
|
+
async def clear_commands(self, guild_ids: list[int] = None):
|
|
213
|
+
"""Clear a guild's or global commands (all types).
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
guild_ids (list[int]): ID of the target guild. If omitted, **global** commands will be cleared.
|
|
217
|
+
"""
|
|
218
|
+
self.command_dispatcher.clear_commands(guild_ids)
|
|
219
|
+
|
|
220
|
+
async def _start_shards(self):
|
|
221
|
+
"""Starts all shards batching by max_concurrency."""
|
|
222
|
+
|
|
223
|
+
from .events.gateway_events import GatewayEvent
|
|
224
|
+
|
|
225
|
+
data = await self._http.request('GET', '/gateway/bot')
|
|
226
|
+
|
|
227
|
+
gateway = GatewayEvent.from_dict(data)
|
|
228
|
+
|
|
229
|
+
# pull important values for easier access
|
|
230
|
+
total_shards = gateway.shards
|
|
231
|
+
batch_size = gateway.session_start_limit.max_concurrency
|
|
232
|
+
|
|
233
|
+
tasks = []
|
|
234
|
+
|
|
235
|
+
for batch_start in range(0, total_shards, batch_size):
|
|
236
|
+
batch_end = min(batch_start + batch_size, total_shards)
|
|
237
|
+
|
|
238
|
+
self._logger.log_info(f"Starting shards {batch_start}-{batch_end - 1} of {total_shards}")
|
|
239
|
+
|
|
240
|
+
for shard_id in range(batch_start, batch_end):
|
|
241
|
+
shard = GatewayClient(self, gateway.url, shard_id, total_shards)
|
|
242
|
+
self.shards.append(shard)
|
|
243
|
+
|
|
244
|
+
# fire and forget
|
|
245
|
+
tasks.append(asyncio.create_task(shard.start()))
|
|
246
|
+
tasks.append(asyncio.create_task(self._listen_shard(shard)))
|
|
247
|
+
|
|
248
|
+
# wait before next batch to respect identify rate limit
|
|
249
|
+
await asyncio.sleep(5)
|
|
250
|
+
|
|
251
|
+
return tasks
|
|
252
|
+
|
|
253
|
+
async def _listen_shard(self, shard: GatewayClient):
|
|
254
|
+
"""Listen to websocket queue for events. Only OP code 0 passes!
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
shard (GatewayClient): the shard or gateway to listen on
|
|
258
|
+
"""
|
|
259
|
+
while True:
|
|
260
|
+
try:
|
|
261
|
+
dispatch_type, event_data = await shard.event_queue.get()
|
|
262
|
+
|
|
263
|
+
# check prefix first (only if a prefix is set)
|
|
264
|
+
if self.prefix_dispatcher.prefix and dispatch_type == 'MESSAGE_CREATE':
|
|
265
|
+
await self.prefix_dispatcher.dispatch(event_data)
|
|
266
|
+
|
|
267
|
+
# then try interaction
|
|
268
|
+
elif dispatch_type == 'INTERACTION_CREATE':
|
|
269
|
+
await self.command_dispatcher.dispatch(event_data)
|
|
270
|
+
|
|
271
|
+
# otherwise this must be an event!
|
|
272
|
+
await self.dispatcher.dispatch(dispatch_type, event_data)
|
|
273
|
+
except:
|
|
274
|
+
break # stop task if an error occurred
|
|
275
|
+
|
|
276
|
+
async def _start(self):
|
|
277
|
+
"""Starts the HTTP/Websocket client, run startup hooks, and registers commands."""
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
await self._http.start(self.token)
|
|
281
|
+
|
|
282
|
+
if self._setup_hooks:
|
|
283
|
+
for hook in self._setup_hooks:
|
|
284
|
+
self._logger.log_info(f"Setting hook {hook.__name__}")
|
|
285
|
+
await hook(self)
|
|
286
|
+
self._logger.log_high_priority("Hooks set up.")
|
|
287
|
+
|
|
288
|
+
if self.sync_commands:
|
|
289
|
+
await self.command_dispatcher.register_guild_commands()
|
|
290
|
+
|
|
291
|
+
await self.command_dispatcher.register_global_commands()
|
|
292
|
+
|
|
293
|
+
self._logger.log_high_priority("Commands set up.")
|
|
294
|
+
|
|
295
|
+
tasks = await asyncio.create_task(self._start_shards())
|
|
296
|
+
|
|
297
|
+
# end all ongoing tasks
|
|
298
|
+
await asyncio.gather(*tasks)
|
|
299
|
+
|
|
300
|
+
except asyncio.CancelledError:
|
|
301
|
+
self._logger.log_high_priority("Connection cancelled via KeyboardInterrupt.")
|
|
302
|
+
except Exception as e:
|
|
303
|
+
self._logger.log_error(f"{type(e).__name__} - {e}")
|
|
304
|
+
finally:
|
|
305
|
+
await self._close()
|
|
306
|
+
|
|
307
|
+
async def _close(self):
|
|
308
|
+
"""Gracefully close HTTP session, websocket connections, and run shutdown hooks."""
|
|
309
|
+
|
|
310
|
+
for hook in self._shutdown_hooks:
|
|
311
|
+
try:
|
|
312
|
+
self._logger.log_info(f"Executing shutdown hook {hook.__name__}")
|
|
313
|
+
await hook(self)
|
|
314
|
+
except Exception as e:
|
|
315
|
+
self._logger.log_error(f"Shutdown hook failed: {type(e).__name__}: {e}")
|
|
316
|
+
|
|
317
|
+
self._logger.log_info("Closing HTTP session...")
|
|
318
|
+
await self._http.close()
|
|
319
|
+
|
|
320
|
+
# close each connection or shard
|
|
321
|
+
for shard in self.shards:
|
|
322
|
+
await shard.close_ws()
|
|
323
|
+
|
|
324
|
+
def run(self):
|
|
325
|
+
"""User-facing entry point for starting the client."""
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
asyncio.run(self._start())
|
|
329
|
+
except KeyboardInterrupt:
|
|
330
|
+
self._logger.log_info("Shutdown requested via KeyboardInterrupt.")
|
|
331
|
+
except Exception as e:
|
|
332
|
+
self._logger.log_error(f"{type(e).__name__} {e}")
|
|
333
|
+
finally:
|
|
334
|
+
self._logger.log_high_priority("Bot shutting down.")
|
|
335
|
+
self._logger.close()
|
|
@@ -5,10 +5,17 @@ from .http import HTTPClient
|
|
|
5
5
|
from .logger import Logger
|
|
6
6
|
|
|
7
7
|
class ClientLike(Protocol):
|
|
8
|
-
"""Exposes a common interface for [`Client`][
|
|
8
|
+
"""Exposes a common interface for [`Client`][scurrypy.client.Client]."""
|
|
9
|
+
|
|
10
|
+
token: str
|
|
11
|
+
"""Bot's token."""
|
|
12
|
+
|
|
9
13
|
application_id: int
|
|
10
14
|
"""Bot's application ID."""
|
|
11
15
|
|
|
16
|
+
intents: int
|
|
17
|
+
"""Bot intents for listening to events."""
|
|
18
|
+
|
|
12
19
|
config: BaseConfig
|
|
13
20
|
"""User-defined config."""
|
|
14
21
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
|
|
3
|
+
from ..client_like import ClientLike
|
|
4
|
+
|
|
5
|
+
from ..events.interaction_events import ApplicationCommandData, MessageComponentData, ModalData, InteractionEvent
|
|
6
|
+
from ..resources.interaction import Interaction, InteractionDataTypes
|
|
7
|
+
from ..parts.command import SlashCommand, MessageCommand, UserCommand
|
|
8
|
+
|
|
9
|
+
class InteractionTypes:
|
|
10
|
+
"""Interaction types constants."""
|
|
11
|
+
|
|
12
|
+
APPLICATION_COMMAND = 2
|
|
13
|
+
"""Slash command interaction."""
|
|
14
|
+
|
|
15
|
+
MESSAGE_COMPONENT = 3
|
|
16
|
+
"""Message component interaction (e.g., button, select menu, etc.)."""
|
|
17
|
+
|
|
18
|
+
MODAL_SUBMIT = 5
|
|
19
|
+
"""Modal submit interaction."""
|
|
20
|
+
|
|
21
|
+
class CommandDispatcher:
|
|
22
|
+
"""Central hub for registering and dispatching interaction responses."""
|
|
23
|
+
|
|
24
|
+
RESOURCE_MAP = { # maps discord events to their respective dataclass
|
|
25
|
+
InteractionTypes.APPLICATION_COMMAND: ApplicationCommandData,
|
|
26
|
+
InteractionTypes.MESSAGE_COMPONENT: MessageComponentData,
|
|
27
|
+
InteractionTypes.MODAL_SUBMIT: ModalData
|
|
28
|
+
}
|
|
29
|
+
"""Maps [`InteractionTypes`][scurrypy.dispatch.command_dispatcher.InteractionTypes] to their respective dataclass."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, client: ClientLike):
|
|
32
|
+
|
|
33
|
+
self.application_id = client.application_id
|
|
34
|
+
"""Bot's application ID."""
|
|
35
|
+
|
|
36
|
+
self.bot = client
|
|
37
|
+
"""Bot session for user access to bot."""
|
|
38
|
+
|
|
39
|
+
self._http = client._http
|
|
40
|
+
"""HTTP session for requests."""
|
|
41
|
+
|
|
42
|
+
self._logger = client._logger
|
|
43
|
+
"""Logger instance to log events."""
|
|
44
|
+
|
|
45
|
+
self._global_commands = []
|
|
46
|
+
"""List of all Global commands."""
|
|
47
|
+
|
|
48
|
+
self._guild_commands = {}
|
|
49
|
+
"""Guild commands mapped by guild ID."""
|
|
50
|
+
|
|
51
|
+
self.component_handlers = {}
|
|
52
|
+
"""Mapping of component custom IDs to handler."""
|
|
53
|
+
|
|
54
|
+
self.slash_handlers = {}
|
|
55
|
+
"""Mapping of command names to handler."""
|
|
56
|
+
|
|
57
|
+
self.message_handlers = {}
|
|
58
|
+
"""Mapping of message command names to handler."""
|
|
59
|
+
|
|
60
|
+
self.user_handlers = {}
|
|
61
|
+
"""Mapping of user command names to handler."""
|
|
62
|
+
|
|
63
|
+
async def register_guild_commands(self):
|
|
64
|
+
"""Registers commands at the guild level."""
|
|
65
|
+
|
|
66
|
+
for guild_id, cmds in self._guild_commands.items():
|
|
67
|
+
# register commands PER GUILD
|
|
68
|
+
await self._http.request(
|
|
69
|
+
'PUT',
|
|
70
|
+
f"applications/{self.application_id}/guilds/{guild_id}/commands",
|
|
71
|
+
data=[command.to_dict() for command in cmds]
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def register_global_commands(self):
|
|
75
|
+
"""Registers a command at the global/bot level. (ALL GUILDS)"""
|
|
76
|
+
|
|
77
|
+
await self._http.request(
|
|
78
|
+
'PUT',
|
|
79
|
+
f"applications/{self.application_id}/commands",
|
|
80
|
+
data=[command.to_dict() for command in self._global_commands]
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def _queue_command(self, command: SlashCommand | UserCommand | MessageCommand, guild_ids: list[int] = None):
|
|
84
|
+
"""Queue a command to be registered by Discord.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
command (SlashCommand | UserCommand | MessageCommand): the command object
|
|
88
|
+
guild_ids (list[int], optional): guild IDs to register command to (if any)
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
if guild_ids:
|
|
92
|
+
gids = [guild_ids] if isinstance(guild_ids, int) else guild_ids
|
|
93
|
+
for gid in gids:
|
|
94
|
+
self._guild_commands.setdefault(gid, []).append(command)
|
|
95
|
+
else:
|
|
96
|
+
self._global_commands.append(command)
|
|
97
|
+
|
|
98
|
+
def clear_commands(self, guild_ids: list[int] = None):
|
|
99
|
+
"""Clear a guild's or global commands (all types).
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
guild_ids (list[int], optional): guild IDs to register command to (if any)
|
|
103
|
+
"""
|
|
104
|
+
if guild_ids:
|
|
105
|
+
gids = [guild_ids] if isinstance(guild_ids, int) else guild_ids
|
|
106
|
+
for gid in gids:
|
|
107
|
+
removed = self._guild_commands.pop(gid, None)
|
|
108
|
+
if removed is None:
|
|
109
|
+
self._logger.log_warn(f"Guild ID {gid} not found; skipping...")
|
|
110
|
+
else:
|
|
111
|
+
self._global_commands.clear()
|
|
112
|
+
|
|
113
|
+
def add_slash_command(self, command: SlashCommand, handler, guild_ids: list[int]):
|
|
114
|
+
"""Add a slash command to be registered by Discord.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
command (SlashCommand): the command object
|
|
118
|
+
handler (callable): user-defined callback when this command is invoked
|
|
119
|
+
guild_ids (list[int]): guild IDs to register command to (if any)
|
|
120
|
+
"""
|
|
121
|
+
self.slash_handlers[command.name] = handler
|
|
122
|
+
self._queue_command(command, guild_ids)
|
|
123
|
+
|
|
124
|
+
def add_message_command(self, command: MessageCommand, handler, guild_ids: list[int]):
|
|
125
|
+
"""Add a slash command to be registered by Discord.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
command (MessageCommand): the command object
|
|
129
|
+
handler (callable): user-defined callback when this command is invoked
|
|
130
|
+
guild_ids (list[int]): guild IDs to register command to (if any)
|
|
131
|
+
"""
|
|
132
|
+
self.message_handlers[command.name] = handler
|
|
133
|
+
self._queue_command(command, guild_ids)
|
|
134
|
+
|
|
135
|
+
def add_user_command(self, command: UserCommand, handler, guild_ids: list[int]):
|
|
136
|
+
"""Add a user command to be registered by Discord.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
command (UserCommand): the command object
|
|
140
|
+
handler (callable): user-defined callback when this command is invoked
|
|
141
|
+
guild_ids (list[int]): guild IDs to register command to (if any)
|
|
142
|
+
"""
|
|
143
|
+
self.user_handlers[command.name] = handler
|
|
144
|
+
self._queue_command(command, guild_ids)
|
|
145
|
+
|
|
146
|
+
def component(self, func, custom_id: str):
|
|
147
|
+
"""Decorator to register component interactions.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
custom_id (str): Identifier of the component
|
|
151
|
+
!!! warning "Important"
|
|
152
|
+
Must match the `custom_id` set where the component was created.
|
|
153
|
+
"""
|
|
154
|
+
self.component_handlers[custom_id] = func
|
|
155
|
+
|
|
156
|
+
def _get_handler(self, name: str):
|
|
157
|
+
"""Helper function for fetching a handler by `fnmatch`."""
|
|
158
|
+
for k, v in self.component_handlers.items():
|
|
159
|
+
if fnmatch.fnmatch(name, k) == True:
|
|
160
|
+
return v
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
async def dispatch(self, data: dict):
|
|
164
|
+
"""Dispatch a response to an `INTERACTION_CREATE` event
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
data (dict): interaction data
|
|
168
|
+
"""
|
|
169
|
+
event = InteractionEvent(interaction=Interaction.from_dict(data, self._http))
|
|
170
|
+
|
|
171
|
+
event_data_obj = self.RESOURCE_MAP.get(event.interaction.type)
|
|
172
|
+
|
|
173
|
+
if not event_data_obj:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
event.data = event_data_obj.from_dict(data.get('data'))
|
|
177
|
+
handler = None
|
|
178
|
+
name = None
|
|
179
|
+
|
|
180
|
+
match event.interaction.type:
|
|
181
|
+
case InteractionTypes.APPLICATION_COMMAND:
|
|
182
|
+
name = event.data.name
|
|
183
|
+
|
|
184
|
+
match event.data.type:
|
|
185
|
+
case InteractionDataTypes.SLASH_COMMAND:
|
|
186
|
+
handler = self.slash_handlers.get(name)
|
|
187
|
+
case InteractionDataTypes.USER_COMMAND:
|
|
188
|
+
handler = self.user_handlers.get(name)
|
|
189
|
+
case InteractionDataTypes.MESSAGE_COMMAND:
|
|
190
|
+
handler = self.message_handlers.get(name)
|
|
191
|
+
|
|
192
|
+
# BOTH modals and message components have custom IDs!
|
|
193
|
+
case InteractionTypes.MESSAGE_COMPONENT | InteractionTypes.MODAL_SUBMIT:
|
|
194
|
+
name = event.data.custom_id
|
|
195
|
+
handler = self._get_handler(name)
|
|
196
|
+
|
|
197
|
+
if not handler:
|
|
198
|
+
self._logger.log_warn(f"No handler registered for interaction '{name}'")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
await handler(self.bot, event)
|
|
203
|
+
self._logger.log_info(f"Interaction Event '{name}' Acknowledged.")
|
|
204
|
+
except Exception as e:
|
|
205
|
+
self._logger.log_error(f"Error in interaction '{name}': {e}")
|
|
@@ -6,33 +6,33 @@ from ..resources.message import Message
|
|
|
6
6
|
class EventDispatcher:
|
|
7
7
|
"""Central hub for handling Discord Gateway events."""
|
|
8
8
|
|
|
9
|
-
RESOURCE_MAP = {
|
|
10
|
-
'READY': ('
|
|
9
|
+
RESOURCE_MAP = {
|
|
10
|
+
'READY': ('scurrypy.events.ready_event', 'ReadyEvent'),
|
|
11
11
|
|
|
12
|
-
'GUILD_CREATE': ('
|
|
13
|
-
'GUILD_UPDATE': ('
|
|
14
|
-
'GUILD_DELETE': ('
|
|
12
|
+
'GUILD_CREATE': ('scurrypy.events.guild_events', 'GuildCreateEvent'),
|
|
13
|
+
'GUILD_UPDATE': ('scurrypy.events.guild_events', 'GuildUpdateEvent'),
|
|
14
|
+
'GUILD_DELETE': ('scurrypy.events.guild_events', 'GuildDeleteEvent'),
|
|
15
15
|
|
|
16
|
-
'CHANNEL_CREATE': ('
|
|
17
|
-
'CHANNEL_UPDATE': ('
|
|
18
|
-
'CHANNEL_DELETE': ('
|
|
19
|
-
'CHANNEL_PINS_UPDATE': ('
|
|
16
|
+
'CHANNEL_CREATE': ('scurrypy.events.channel_events', 'GuildChannelCreateEvent'),
|
|
17
|
+
'CHANNEL_UPDATE': ('scurrypy.events.channel_events', 'GuildChannelUpdateEvent'),
|
|
18
|
+
'CHANNEL_DELETE': ('scurrypy.events.channel_events', 'GuildChannelDeleteEvent'),
|
|
19
|
+
'CHANNEL_PINS_UPDATE': ('scurrypy.events.channel_events', 'ChannelPinsUpdateEvent'),
|
|
20
20
|
|
|
21
|
-
'MESSAGE_CREATE': ('
|
|
22
|
-
'MESSAGE_UPDATE': ('
|
|
23
|
-
'MESSAGE_DELETE': ('
|
|
21
|
+
'MESSAGE_CREATE': ('scurrypy.events.message_events', 'MessageCreateEvent'),
|
|
22
|
+
'MESSAGE_UPDATE': ('scurrypy.events.message_events', 'MessageUpdateEvent'),
|
|
23
|
+
'MESSAGE_DELETE': ('scurrypy.events.message_events', 'MessageDeleteEvent'),
|
|
24
24
|
|
|
25
|
-
'MESSAGE_REACTION_ADD': ('
|
|
26
|
-
'MESSAGE_REACTION_REMOVE': ('
|
|
27
|
-
'MESSAGE_REACTION_REMOVE_ALL': ('
|
|
28
|
-
'MESSAGE_REACTION_REMOVE_EMOJI': ('
|
|
25
|
+
'MESSAGE_REACTION_ADD': ('scurrypy.events.reaction_events', 'ReactionAddEvent'),
|
|
26
|
+
'MESSAGE_REACTION_REMOVE': ('scurrypy.events.reaction_events', 'ReactionRemoveEvent'),
|
|
27
|
+
'MESSAGE_REACTION_REMOVE_ALL': ('scurrypy.events.reaction_events', 'ReactionRemoveAllEvent'),
|
|
28
|
+
'MESSAGE_REACTION_REMOVE_EMOJI': ('scurrypy.events.reaction_events', 'ReactionRemoveEmojiEvent'),
|
|
29
29
|
|
|
30
30
|
# and other events...
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
"""Mapping of event names to respective dataclass."""
|
|
32
|
+
"""Mapping of event names to respective dataclass. (lazy loading)"""
|
|
34
33
|
|
|
35
34
|
def __init__(self, client: ClientLike):
|
|
35
|
+
|
|
36
36
|
self.application_id = client.application_id
|
|
37
37
|
"""Bot's ID."""
|
|
38
38
|
|
|
@@ -43,7 +43,7 @@ class EventDispatcher:
|
|
|
43
43
|
"""HTTP session for requests."""
|
|
44
44
|
|
|
45
45
|
self._logger = client._logger
|
|
46
|
-
"""
|
|
46
|
+
"""Logger instance to log events."""
|
|
47
47
|
|
|
48
48
|
self.config = client.config
|
|
49
49
|
"""User-defined bot config for persistent data."""
|
|
@@ -77,12 +77,12 @@ class EventDispatcher:
|
|
|
77
77
|
|
|
78
78
|
module = importlib.import_module(module_name)
|
|
79
79
|
if not module:
|
|
80
|
-
|
|
80
|
+
self._logger.log_error(f"Cannot find module '{module_name}'!")
|
|
81
81
|
return
|
|
82
82
|
|
|
83
83
|
cls = getattr(module, class_name)
|
|
84
84
|
if not cls:
|
|
85
|
-
|
|
85
|
+
self._logger.log_error(f"Cannot find class '{class_name}'!")
|
|
86
86
|
return
|
|
87
87
|
|
|
88
88
|
if isinstance(cls, Message) and cls.author.id == self.application_id:
|