scurrypy 0.1.0__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.
Potentially problematic release.
This version of scurrypy might be problematic. Click here for more details.
- scurrypy-0.1.0/LICENSE +5 -0
- scurrypy-0.1.0/PKG-INFO +8 -0
- scurrypy-0.1.0/README.md +12 -0
- scurrypy-0.1.0/discord/__init__.py +9 -0
- scurrypy-0.1.0/discord/client.py +312 -0
- scurrypy-0.1.0/discord/dispatch/__init__.py +1 -0
- scurrypy-0.1.0/discord/dispatch/command_dispatcher.py +156 -0
- scurrypy-0.1.0/discord/dispatch/event_dispatcher.py +85 -0
- scurrypy-0.1.0/discord/dispatch/prefix_dispatcher.py +53 -0
- scurrypy-0.1.0/discord/error.py +63 -0
- scurrypy-0.1.0/discord/events/__init__.py +33 -0
- scurrypy-0.1.0/discord/events/channel_events.py +52 -0
- scurrypy-0.1.0/discord/events/guild_events.py +38 -0
- scurrypy-0.1.0/discord/events/hello_event.py +9 -0
- scurrypy-0.1.0/discord/events/interaction_events.py +145 -0
- scurrypy-0.1.0/discord/events/message_events.py +43 -0
- scurrypy-0.1.0/discord/events/reaction_events.py +99 -0
- scurrypy-0.1.0/discord/events/ready_event.py +30 -0
- scurrypy-0.1.0/discord/gateway.py +175 -0
- scurrypy-0.1.0/discord/http.py +292 -0
- scurrypy-0.1.0/discord/intents.py +87 -0
- scurrypy-0.1.0/discord/logger.py +147 -0
- scurrypy-0.1.0/discord/model.py +88 -0
- scurrypy-0.1.0/discord/models/__init__.py +8 -0
- scurrypy-0.1.0/discord/models/application.py +37 -0
- scurrypy-0.1.0/discord/models/emoji.py +34 -0
- scurrypy-0.1.0/discord/models/guild.py +35 -0
- scurrypy-0.1.0/discord/models/integration.py +23 -0
- scurrypy-0.1.0/discord/models/member.py +27 -0
- scurrypy-0.1.0/discord/models/role.py +53 -0
- scurrypy-0.1.0/discord/models/user.py +15 -0
- scurrypy-0.1.0/discord/parts/__init__.py +28 -0
- scurrypy-0.1.0/discord/parts/action_row.py +258 -0
- scurrypy-0.1.0/discord/parts/attachment.py +18 -0
- scurrypy-0.1.0/discord/parts/channel.py +20 -0
- scurrypy-0.1.0/discord/parts/command.py +102 -0
- scurrypy-0.1.0/discord/parts/component_types.py +5 -0
- scurrypy-0.1.0/discord/parts/components_v2.py +270 -0
- scurrypy-0.1.0/discord/parts/embed.py +154 -0
- scurrypy-0.1.0/discord/parts/message.py +179 -0
- scurrypy-0.1.0/discord/parts/modal.py +21 -0
- scurrypy-0.1.0/discord/parts/role.py +39 -0
- scurrypy-0.1.0/discord/resources/__init__.py +10 -0
- scurrypy-0.1.0/discord/resources/application.py +94 -0
- scurrypy-0.1.0/discord/resources/bot_emojis.py +49 -0
- scurrypy-0.1.0/discord/resources/channel.py +192 -0
- scurrypy-0.1.0/discord/resources/guild.py +265 -0
- scurrypy-0.1.0/discord/resources/interaction.py +155 -0
- scurrypy-0.1.0/discord/resources/message.py +223 -0
- scurrypy-0.1.0/discord/resources/user.py +111 -0
- scurrypy-0.1.0/pyproject.toml +22 -0
- scurrypy-0.1.0/scurrypy.egg-info/PKG-INFO +8 -0
- scurrypy-0.1.0/scurrypy.egg-info/SOURCES.txt +54 -0
- scurrypy-0.1.0/scurrypy.egg-info/dependency_links.txt +1 -0
- scurrypy-0.1.0/scurrypy.egg-info/top_level.txt +1 -0
- scurrypy-0.1.0/setup.cfg +4 -0
scurrypy-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Copyright (c) 2025 Furmissile. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Derivative works are not permitted -- the source code is made available for educational and reference purposes only, and is not exempt from licensing.
|
|
4
|
+
This software is provided on an "as-is" basis and the author is not responsible for any liability that may come from misuse.
|
|
5
|
+
No commercial profit may be drawn off of derivatives of this software project for any reason.
|
scurrypy-0.1.0/PKG-INFO
ADDED
scurrypy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# __Welcome to ScurryPy__
|
|
2
|
+
Yet another Discord API wrapper in Python!
|
|
3
|
+
|
|
4
|
+
While this wrapper is mainly used for various squirrel-related shenanigans, it can also be used for more generic bot purposes.
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
* Command and event handling
|
|
8
|
+
* Declarative style using decorators
|
|
9
|
+
* Supports both legacy and new features
|
|
10
|
+
* Respects Discord's rate limits
|
|
11
|
+
|
|
12
|
+
See the docs for more!
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from .logger import Logger
|
|
4
|
+
from .gateway import GatewayClient
|
|
5
|
+
from .http import HTTPClient
|
|
6
|
+
from .intents import Intents
|
|
7
|
+
from .error import DiscordError
|
|
8
|
+
|
|
9
|
+
from .resources.guild import Guild
|
|
10
|
+
from .resources.channel import Channel
|
|
11
|
+
from .resources.message import Message
|
|
12
|
+
from .resources.bot_emojis import BotEmojis
|
|
13
|
+
from .resources.user import User
|
|
14
|
+
from .resources.application import Application
|
|
15
|
+
|
|
16
|
+
from .parts.command import SlashCommand, MessageCommand, UserCommand
|
|
17
|
+
|
|
18
|
+
from .dispatch.event_dispatcher import EventDispatcher
|
|
19
|
+
from .dispatch.prefix_dispatcher import PrefixDispatcher
|
|
20
|
+
from .dispatch.command_dispatcher import CommandDispatcher
|
|
21
|
+
|
|
22
|
+
class Client:
|
|
23
|
+
"""Main entry point for Discord bots.
|
|
24
|
+
Ties together the moving parts: gateway, HTTP, event dispatching, command handling, and resource managers.
|
|
25
|
+
"""
|
|
26
|
+
def __init__(self,
|
|
27
|
+
*,
|
|
28
|
+
token: str,
|
|
29
|
+
application_id: int,
|
|
30
|
+
intents: int = Intents.DEFAULT,
|
|
31
|
+
debug_mode: bool = False,
|
|
32
|
+
prefix = None,
|
|
33
|
+
quiet: bool = False
|
|
34
|
+
):
|
|
35
|
+
"""
|
|
36
|
+
Args:
|
|
37
|
+
token (str): the bot's token
|
|
38
|
+
application_id (int): the bot's user ID
|
|
39
|
+
intents (int, optional): gateway intents. Defaults to Intents.DEFAULT.
|
|
40
|
+
debug_mode (bool, optional): toggle debug messages. Defaults to False.
|
|
41
|
+
prefix (str, optional): set message prefix if using command prefixes
|
|
42
|
+
quiet (bool, optional): if INFO, DEBUG, and WARN should be logged
|
|
43
|
+
"""
|
|
44
|
+
self.token = token
|
|
45
|
+
self.application_id = application_id
|
|
46
|
+
|
|
47
|
+
self._logger = Logger(debug_mode, quiet)
|
|
48
|
+
self._ws = GatewayClient(token, intents, self._logger)
|
|
49
|
+
self._http = HTTPClient(token, self._logger)
|
|
50
|
+
|
|
51
|
+
if prefix and (intents & Intents.MESSAGE_CONTENT == 0):
|
|
52
|
+
self._logger.log_warn('Prefix set without message content enabled.')
|
|
53
|
+
|
|
54
|
+
self.dispatcher = EventDispatcher(self.application_id, self._http, self._logger)
|
|
55
|
+
self.prefix_dispatcher = PrefixDispatcher(self._http, self._logger, prefix)
|
|
56
|
+
self.command_dispatcher = CommandDispatcher(self.application_id, self._http, self._logger)
|
|
57
|
+
|
|
58
|
+
self._global_commands = [] # SlashCommand
|
|
59
|
+
self._guild_commands = {} # {guild_id : [commands], ...}
|
|
60
|
+
|
|
61
|
+
self._is_set_up = False
|
|
62
|
+
self._setup_hooks = []
|
|
63
|
+
|
|
64
|
+
self.emojis = BotEmojis(self._http, self.application_id)
|
|
65
|
+
|
|
66
|
+
def prefix_command(self, func):
|
|
67
|
+
"""Decorator registers prefix commands by the name of the function.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
func (callable): callback handle for command response
|
|
71
|
+
"""
|
|
72
|
+
self.prefix_dispatcher.register(func.__name__, func)
|
|
73
|
+
|
|
74
|
+
def component(self, func):
|
|
75
|
+
"""Decorator registers a function for a component handler.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
func (callable): callback handle for component response
|
|
79
|
+
"""
|
|
80
|
+
self.command_dispatcher.component(func)
|
|
81
|
+
|
|
82
|
+
def command(self, command: SlashCommand | MessageCommand | UserCommand, guild_id: int = 0):
|
|
83
|
+
"""Decorator registers a function for a command handler.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
command (SlashCommand | MessageCommand | UserCommand): command to register
|
|
87
|
+
guild_id (int): ID of guild in which to register command (if a guild command)
|
|
88
|
+
"""
|
|
89
|
+
def decorator(func):
|
|
90
|
+
# hash out command type
|
|
91
|
+
if isinstance(command, MessageCommand):
|
|
92
|
+
self.command_dispatcher.message_command(func)
|
|
93
|
+
elif isinstance(command, UserCommand):
|
|
94
|
+
self.command_dispatcher.user_command(func)
|
|
95
|
+
elif isinstance(command, SlashCommand):
|
|
96
|
+
self.command_dispatcher.command(func)
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError(f'Command {func.__name__} expected to be of type SlashCommand, UserCommand, MessageCommand; \
|
|
99
|
+
got {type(command).__name__}.')
|
|
100
|
+
|
|
101
|
+
# then hash out if this command should be guild or global level
|
|
102
|
+
if guild_id != 0:
|
|
103
|
+
self._guild_commands.setdefault(guild_id, []).append(command)
|
|
104
|
+
else:
|
|
105
|
+
self._global_commands.append(command)
|
|
106
|
+
return decorator
|
|
107
|
+
|
|
108
|
+
def event(self, event_name: str):
|
|
109
|
+
"""Decorator registers a function for an event handler.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
event_name (str): event name (must be a valid event)
|
|
113
|
+
"""
|
|
114
|
+
def decorator(func):
|
|
115
|
+
self.dispatcher.register(event_name, func)
|
|
116
|
+
return func
|
|
117
|
+
return decorator
|
|
118
|
+
|
|
119
|
+
def setup_hook(self, func):
|
|
120
|
+
"""Decorator registers a setup hook.
|
|
121
|
+
(Runs once before the bot starts listening)
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
func (callable): callback to the setup function
|
|
125
|
+
"""
|
|
126
|
+
self._setup_hooks.append(func)
|
|
127
|
+
|
|
128
|
+
def application_from_id(self, application_id: int):
|
|
129
|
+
"""Creates an interactable application resource.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
application_id (int): id of target application
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
(Application): the Application resource
|
|
136
|
+
"""
|
|
137
|
+
return Application(application_id, self._http)
|
|
138
|
+
|
|
139
|
+
def guild_from_id(self, guild_id: int):
|
|
140
|
+
"""Creates an interactable guild resource.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
guild_id (int): id of target guild
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
(Guild): the Guild resource
|
|
147
|
+
"""
|
|
148
|
+
return Guild(guild_id, self._http)
|
|
149
|
+
|
|
150
|
+
def channel_from_id(self, channel_id: int):
|
|
151
|
+
"""Creates an interactable channel resource.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
channel_id (int): id of target channel
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
(Channel): the Channel resource
|
|
158
|
+
"""
|
|
159
|
+
return Channel(channel_id, self._http)
|
|
160
|
+
|
|
161
|
+
def message_from_id(self, channel_id: int, message_id: int):
|
|
162
|
+
"""Creates an interactable message resource.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
message_id (int): id of target message
|
|
166
|
+
channel_id (int): channel id of target message
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
(Message): the Message resource
|
|
170
|
+
"""
|
|
171
|
+
return Message(message_id, channel_id, self._http)
|
|
172
|
+
|
|
173
|
+
def user_from_id(self, user_id: int):
|
|
174
|
+
"""Creates an interactable user resource.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
user_id (int): id of target user
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
(User): the User resource
|
|
181
|
+
"""
|
|
182
|
+
return User(user_id, self._http)
|
|
183
|
+
|
|
184
|
+
async def clear_guild_commands(self, guild_id: int):
|
|
185
|
+
"""Clear a guild's slash commands.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
guild_id (int): id of the target guild
|
|
189
|
+
"""
|
|
190
|
+
if self._guild_commands.get(guild_id):
|
|
191
|
+
self._logger.log_info(f"Guild {guild_id} already queued, skipping clear.")
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
await self.command_dispatcher._register_guild_commands({guild_id: []})
|
|
195
|
+
|
|
196
|
+
async def _listen(self):
|
|
197
|
+
"""Main event loop for incoming gateway requests."""
|
|
198
|
+
while True:
|
|
199
|
+
try:
|
|
200
|
+
message = await self._ws.receive()
|
|
201
|
+
if not message:
|
|
202
|
+
raise ConnectionError("No message received.")
|
|
203
|
+
|
|
204
|
+
op_code = message.get('op')
|
|
205
|
+
|
|
206
|
+
if op_code == 0:
|
|
207
|
+
dispatch_type = message.get('t')
|
|
208
|
+
self._logger.log_info(f"DISPATCH -> {dispatch_type}")
|
|
209
|
+
event_data = message.get('d')
|
|
210
|
+
self._ws.sequence = message.get('s') or self._ws.sequence
|
|
211
|
+
|
|
212
|
+
if dispatch_type == "READY":
|
|
213
|
+
self._ws.session_id = event_data.get("session_id")
|
|
214
|
+
self._ws.connect_url = event_data.get("resume_gateway_url", self._ws.connect_url)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
if self.prefix_dispatcher.prefix and dispatch_type == 'MESSAGE_CREATE':
|
|
218
|
+
await self.prefix_dispatcher.dispatch(event_data)
|
|
219
|
+
|
|
220
|
+
elif dispatch_type == 'INTERACTION_CREATE':
|
|
221
|
+
await self.command_dispatcher.dispatch(event_data)
|
|
222
|
+
|
|
223
|
+
await self.dispatcher.dispatch(dispatch_type, event_data)
|
|
224
|
+
except DiscordError as e:
|
|
225
|
+
if e.fatal:
|
|
226
|
+
raise # let run() handle fatal errors
|
|
227
|
+
else:
|
|
228
|
+
self._logger.log_warn(f"Recoverable DiscordError: {e}")
|
|
229
|
+
continue # keep listening
|
|
230
|
+
|
|
231
|
+
elif op_code == 7:
|
|
232
|
+
raise ConnectionError("Reconnect requested by server.")
|
|
233
|
+
elif op_code == 9:
|
|
234
|
+
self._ws.session_id = None
|
|
235
|
+
self._ws.sequence = None
|
|
236
|
+
raise ConnectionError("Invalid session.")
|
|
237
|
+
except asyncio.CancelledError:
|
|
238
|
+
raise
|
|
239
|
+
except DiscordError as e:
|
|
240
|
+
if e.fatal:
|
|
241
|
+
raise # propagate fatal errors
|
|
242
|
+
else:
|
|
243
|
+
self._logger.log_warn(f"Recoverable DiscordError: {e}")
|
|
244
|
+
except ConnectionError as e:
|
|
245
|
+
self._logger.log_warn(f"Connection lost: {e}")
|
|
246
|
+
await self._ws.close()
|
|
247
|
+
await asyncio.sleep(2)
|
|
248
|
+
|
|
249
|
+
async def start(self):
|
|
250
|
+
"""Runs the main lifecycle of the bot.
|
|
251
|
+
Handles connection setup, heartbeat management, event loop, and automatic reconnects.
|
|
252
|
+
"""
|
|
253
|
+
while True:
|
|
254
|
+
try:
|
|
255
|
+
await self._http.start_session()
|
|
256
|
+
await self._ws.connect()
|
|
257
|
+
await self._ws.start_heartbeat()
|
|
258
|
+
|
|
259
|
+
if self._ws.session_id and self._ws.sequence:
|
|
260
|
+
await self._ws.reconnect()
|
|
261
|
+
else:
|
|
262
|
+
await self._ws.identify()
|
|
263
|
+
|
|
264
|
+
if not self._is_set_up:
|
|
265
|
+
if self._setup_hooks:
|
|
266
|
+
for hook in self._setup_hooks:
|
|
267
|
+
self._logger.log_info(f"Setting hook {hook.__name__}")
|
|
268
|
+
await hook()
|
|
269
|
+
self._logger.log_high_priority("Hooks set up.")
|
|
270
|
+
|
|
271
|
+
# register GUILD commands
|
|
272
|
+
await self.command_dispatcher._register_guild_commands(self._guild_commands)
|
|
273
|
+
|
|
274
|
+
# register GLOBAL commands
|
|
275
|
+
await self.command_dispatcher._register_global_commands(self._global_commands)
|
|
276
|
+
|
|
277
|
+
self._is_set_up = True
|
|
278
|
+
|
|
279
|
+
await self._listen()
|
|
280
|
+
|
|
281
|
+
except ConnectionError as e:
|
|
282
|
+
self._logger.log_warn("Connection lost. Attempting reconnect...")
|
|
283
|
+
await self._ws.close()
|
|
284
|
+
await asyncio.sleep(2)
|
|
285
|
+
continue
|
|
286
|
+
except asyncio.CancelledError:
|
|
287
|
+
self._logger.log_info("Cancelling connection...")
|
|
288
|
+
break
|
|
289
|
+
except DiscordError as e:
|
|
290
|
+
self._logger.log_error(f"Fatal DiscordError: {e}")
|
|
291
|
+
break
|
|
292
|
+
except Exception as e:
|
|
293
|
+
self._logger.log_error(f"Unspecified Error Type {type(e).__name__} - {e}")
|
|
294
|
+
break
|
|
295
|
+
finally:
|
|
296
|
+
await self._ws.close()
|
|
297
|
+
await self._http.close_session()
|
|
298
|
+
|
|
299
|
+
def run(self):
|
|
300
|
+
"""Starts the bot.
|
|
301
|
+
Handles starting the session, WS, and heartbeat, reconnection logic,
|
|
302
|
+
setting up emojis and hooks, and then listens for gateway events.
|
|
303
|
+
"""
|
|
304
|
+
try:
|
|
305
|
+
asyncio.run(self.start())
|
|
306
|
+
except KeyboardInterrupt:
|
|
307
|
+
self._logger.log_debug("Shutdown requested via KeyboardInterrupt.")
|
|
308
|
+
except Exception as e:
|
|
309
|
+
self._logger.log_error(f"{type(e).__name__} {e}")
|
|
310
|
+
finally:
|
|
311
|
+
self._logger.log_high_priority("Bot shutting down.")
|
|
312
|
+
self._logger.close()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# discord/dispatch
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from ..http import HTTPClient
|
|
4
|
+
from ..logger import Logger
|
|
5
|
+
|
|
6
|
+
from ..events.interaction_events import ApplicationCommandData, MessageComponentData, ModalData, InteractionEvent
|
|
7
|
+
from ..resources.interaction import Interaction, InteractionDataTypes
|
|
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`][discord.dispatch.command_dispatcher.InteractionTypes] to their respective dataclass."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, application_id: int, http: HTTPClient, logger: Logger):
|
|
32
|
+
self.application_id = application_id
|
|
33
|
+
"""Bot's application ID."""
|
|
34
|
+
|
|
35
|
+
self._http = http
|
|
36
|
+
"""HTTP session for requests."""
|
|
37
|
+
|
|
38
|
+
self._logger = logger
|
|
39
|
+
"""Logger instance to log events."""
|
|
40
|
+
|
|
41
|
+
self._component_handlers = {}
|
|
42
|
+
"""Mapping of component custom IDs to handler."""
|
|
43
|
+
|
|
44
|
+
self._handlers = {}
|
|
45
|
+
"""Mapping of command names to handler."""
|
|
46
|
+
|
|
47
|
+
self._message_handlers = {}
|
|
48
|
+
"""Mapping of message command names to handler."""
|
|
49
|
+
|
|
50
|
+
self._user_handlers = {}
|
|
51
|
+
"""Mapping of user command names to handler."""
|
|
52
|
+
|
|
53
|
+
async def _register_guild_commands(self, commands: dict):
|
|
54
|
+
"""Registers a command at the guild level.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
commands (dict): mapping of guild IDs to respective serialized command data
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
for guild_id, cmds in commands.items():
|
|
61
|
+
# register commands PER GUILD
|
|
62
|
+
await self._http.request(
|
|
63
|
+
'PUT',
|
|
64
|
+
f"applications/{self.application_id}/guilds/{guild_id}/commands",
|
|
65
|
+
[command._to_dict() for command in cmds]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def _register_global_commands(self, commands: list):
|
|
69
|
+
"""Registers a command at the global/bot level. (ALL GUILDS)
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
commands (list): list of serialized commands
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
global_commands = [command._to_dict() for command in commands]
|
|
76
|
+
|
|
77
|
+
await self._http.request('PUT', f"applications/{self.application_id}/commands", global_commands)
|
|
78
|
+
|
|
79
|
+
def command(self, handler):
|
|
80
|
+
"""Decorator to register slash commands.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
handler (callable): callback handle for command response
|
|
84
|
+
"""
|
|
85
|
+
self._handlers[handler.__name__] = handler
|
|
86
|
+
|
|
87
|
+
def component(self, handler):
|
|
88
|
+
"""Decorator to register component interactions.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
handler (callable): callback handle for component response
|
|
92
|
+
"""
|
|
93
|
+
self._component_handlers[handler.__name__] = handler
|
|
94
|
+
|
|
95
|
+
def user_command(self, handler):
|
|
96
|
+
"""Decorator to register user commands.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
handler (callable): callback handle for user command response
|
|
100
|
+
"""
|
|
101
|
+
self._user_handlers[handler.__name__] = handler
|
|
102
|
+
|
|
103
|
+
def message_command(self, handler):
|
|
104
|
+
"""Decorator to register message commands.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
handler (callable): callback handle for message command response
|
|
108
|
+
"""
|
|
109
|
+
self._message_handlers[handler.__name__] = handler
|
|
110
|
+
|
|
111
|
+
async def dispatch(self, data: dict):
|
|
112
|
+
"""Dispatch a response to an `INTERACTION_CREATE` event
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
data (dict): interaction data
|
|
116
|
+
"""
|
|
117
|
+
event = InteractionEvent(interaction=Interaction.from_dict(data, self._http))
|
|
118
|
+
|
|
119
|
+
event_data_obj = self.RESOURCE_MAP.get(event.interaction.type)
|
|
120
|
+
|
|
121
|
+
if not event_data_obj:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
event.data = event_data_obj.from_dict(data.get('data'))
|
|
125
|
+
handler = None
|
|
126
|
+
name = None
|
|
127
|
+
|
|
128
|
+
match event.interaction.type:
|
|
129
|
+
case InteractionTypes.APPLICATION_COMMAND:
|
|
130
|
+
name = event.data.name
|
|
131
|
+
|
|
132
|
+
match event.data.type:
|
|
133
|
+
case InteractionDataTypes.SLASH_COMMAND:
|
|
134
|
+
handler = self._handlers.get(name)
|
|
135
|
+
case InteractionDataTypes.USER_COMMAND:
|
|
136
|
+
handler = self._user_handlers.get(name)
|
|
137
|
+
case InteractionDataTypes.MESSAGE_COMMAND:
|
|
138
|
+
handler = self._message_handlers.get(name)
|
|
139
|
+
|
|
140
|
+
case InteractionTypes.MESSAGE_COMPONENT:
|
|
141
|
+
name = event.data.custom_id
|
|
142
|
+
handler = self._component_handlers.get(name)
|
|
143
|
+
|
|
144
|
+
case InteractionTypes.MODAL_SUBMIT:
|
|
145
|
+
name = event.data.custom_id
|
|
146
|
+
handler = self._component_handlers.get(name)
|
|
147
|
+
|
|
148
|
+
if not handler:
|
|
149
|
+
self._logger.log_warn(f"No handler registered for interaction '{name}'")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
await handler(event) # NOTE: treat command options as args!
|
|
154
|
+
self._logger.log_info(f"Interaction Event '{name}' Acknowledged.")
|
|
155
|
+
except Exception as e:
|
|
156
|
+
self._logger.log_error(f"Error in interaction '{name}': {e}")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from ..http import HTTPClient
|
|
2
|
+
from ..logger import Logger
|
|
3
|
+
|
|
4
|
+
from ..events.ready_event import *
|
|
5
|
+
from ..events.reaction_events import *
|
|
6
|
+
from ..events.guild_events import *
|
|
7
|
+
from ..events.message_events import *
|
|
8
|
+
from ..events.channel_events import *
|
|
9
|
+
from ..events.interaction_events import *
|
|
10
|
+
|
|
11
|
+
from ..resources.message import Message
|
|
12
|
+
|
|
13
|
+
class EventDispatcher:
|
|
14
|
+
"""Central hub for handling Discord Gateway events."""
|
|
15
|
+
RESOURCE_MAP = { # maps discord events to their respective dataclass
|
|
16
|
+
'READY': ReadyEvent,
|
|
17
|
+
|
|
18
|
+
"MESSAGE_CREATE": MessageCreateEvent,
|
|
19
|
+
"MESSAGE_UPDATE": MessageUpdateEvent,
|
|
20
|
+
'MESSAGE_DELETE': MessageDeleteEvent,
|
|
21
|
+
|
|
22
|
+
'MESSAGE_REACTION_ADD': ReactionAddEvent,
|
|
23
|
+
'MESSAGE_REACTION_REMOVE': ReactionRemoveEvent,
|
|
24
|
+
'MESSAGE_REACTION_REMOVE_ALL': ReactionRemoveAllEvent,
|
|
25
|
+
'MESSAGE_REACTION_REMOVE_EMOJI': ReactionRemoveEmojiEvent,
|
|
26
|
+
|
|
27
|
+
'CHANNEL_CREATE': GuildChannelCreateEvent,
|
|
28
|
+
'CHANNEL_UPDATE': GuildChannelUpdateEvent,
|
|
29
|
+
'CHANNEL_DELETE': GuildChannelDeleteEvent,
|
|
30
|
+
|
|
31
|
+
'CHANNEL_PINS_UPDATE': ChannelPinsUpdateEvent,
|
|
32
|
+
|
|
33
|
+
'GUILD_CREATE': GuildCreateEvent,
|
|
34
|
+
'GUILD_UPDATE': GuildUpdateEvent,
|
|
35
|
+
'GUILD_DELETE': GuildDeleteEvent,
|
|
36
|
+
|
|
37
|
+
'INTERACTION_CREATE': InteractionEvent
|
|
38
|
+
|
|
39
|
+
# and other events...
|
|
40
|
+
}
|
|
41
|
+
"""Mapping of event names to respective dataclass."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, application_id: int, http: HTTPClient, logger: Logger):
|
|
44
|
+
self.application_id = application_id
|
|
45
|
+
"""Bot's ID."""
|
|
46
|
+
|
|
47
|
+
self._http = http
|
|
48
|
+
"""HTTP session for requests."""
|
|
49
|
+
|
|
50
|
+
self._logger = logger
|
|
51
|
+
"""HTTP session for requests"""
|
|
52
|
+
|
|
53
|
+
self._handlers = {}
|
|
54
|
+
"""Mapping of event names to handler."""
|
|
55
|
+
|
|
56
|
+
def register(self, event_name: str, handler):
|
|
57
|
+
"""Registers the given handler to the given event name.
|
|
58
|
+
(Event name must be a valid Discord event)
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
event_name (str): name of the event
|
|
62
|
+
handler (callable): callback to handle event
|
|
63
|
+
"""
|
|
64
|
+
self._handlers[event_name] = handler
|
|
65
|
+
|
|
66
|
+
async def dispatch(self, event_name: str, data: dict):
|
|
67
|
+
"""Hydrate the corresponding dataclass and call the handler.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
event_name (str): name of the event
|
|
71
|
+
data (dict): Discord's raw event payload
|
|
72
|
+
"""
|
|
73
|
+
cls = self.RESOURCE_MAP.get(event_name)
|
|
74
|
+
|
|
75
|
+
if not cls:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if isinstance(cls, Message) and cls.author.id == self.application_id:
|
|
79
|
+
return # ignore bot's own messages
|
|
80
|
+
|
|
81
|
+
handler = self._handlers.get(event_name, None)
|
|
82
|
+
if handler:
|
|
83
|
+
obj = cls.from_dict(data, self._http)
|
|
84
|
+
await handler(obj)
|
|
85
|
+
self._logger.log_info(f"Event {event_name} Acknowledged.")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from ..http import HTTPClient
|
|
2
|
+
from ..logger import Logger
|
|
3
|
+
|
|
4
|
+
from ..events.message_events import MessageCreateEvent
|
|
5
|
+
|
|
6
|
+
from ..resources.message import Message
|
|
7
|
+
from ..models.member import MemberModel
|
|
8
|
+
|
|
9
|
+
class PrefixDispatcher:
|
|
10
|
+
"""Handles text-based command messages that start with a specific prefix."""
|
|
11
|
+
def __init__(self, http: HTTPClient, logger: Logger, prefix: str):
|
|
12
|
+
self._http = http
|
|
13
|
+
"""HTTP session for requests."""
|
|
14
|
+
|
|
15
|
+
self._logger = logger
|
|
16
|
+
"""Logger instance to log events."""
|
|
17
|
+
|
|
18
|
+
self.prefix = prefix
|
|
19
|
+
"""User-defined command prefix."""
|
|
20
|
+
|
|
21
|
+
self._handlers = {}
|
|
22
|
+
"""Mapping of command prefix names to handler"""
|
|
23
|
+
|
|
24
|
+
def register(self, name: str, handler):
|
|
25
|
+
"""Registers a handler for a command name (case-insensitive)
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
name (str): name of handler (and command)
|
|
29
|
+
handler (callable): handler callback
|
|
30
|
+
"""
|
|
31
|
+
self._handlers[name.lower()] = handler
|
|
32
|
+
|
|
33
|
+
async def dispatch(self, data: dict):
|
|
34
|
+
"""Hydrate the corresponding dataclass and call the handler.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
data (dict): Discord's raw event payload
|
|
38
|
+
"""
|
|
39
|
+
event = MessageCreateEvent(
|
|
40
|
+
guild_id=data.get('guild_id'),
|
|
41
|
+
message=Message.from_dict(data, self._http),
|
|
42
|
+
member=MemberModel.from_dict(data.get('member'))
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if event.message._has_prefix(self.prefix):
|
|
46
|
+
command, *args = event.message._extract_args(self.prefix)
|
|
47
|
+
handler = self._handlers.get(command)
|
|
48
|
+
if handler:
|
|
49
|
+
try:
|
|
50
|
+
await handler(event, *args)
|
|
51
|
+
self._logger.log_info(f"Prefix Event '{command}' Acknowledged.")
|
|
52
|
+
except Exception as e:
|
|
53
|
+
self._logger.log_error(f"Error in prefix command '{command}': {e}")
|