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
discord/client.py
DELETED
|
@@ -1,375 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
|
|
3
|
-
from .config import BaseConfig
|
|
4
|
-
from .intents import Intents
|
|
5
|
-
from .error import DiscordError
|
|
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
|
-
prefix = None,
|
|
22
|
-
quiet: bool = False
|
|
23
|
-
):
|
|
24
|
-
"""
|
|
25
|
-
Args:
|
|
26
|
-
token (str): the bot's token
|
|
27
|
-
application_id (int): the bot's user ID
|
|
28
|
-
intents (int, optional): gateway intents. Defaults to Intents.DEFAULT.
|
|
29
|
-
config (BaseConfig, optional): user-defined config data
|
|
30
|
-
debug_mode (bool, optional): toggle debug messages. Defaults to False.
|
|
31
|
-
prefix (str, optional): set message prefix if using command prefixes
|
|
32
|
-
quiet (bool, optional): if INFO, DEBUG, and WARN should be logged
|
|
33
|
-
"""
|
|
34
|
-
if not token:
|
|
35
|
-
raise ValueError("Token is required")
|
|
36
|
-
if not application_id:
|
|
37
|
-
raise ValueError("Application ID is required")
|
|
38
|
-
|
|
39
|
-
from .logger import Logger
|
|
40
|
-
from .gateway import GatewayClient
|
|
41
|
-
from .http import HTTPClient
|
|
42
|
-
from .resources.bot_emojis import BotEmojis
|
|
43
|
-
from .dispatch.event_dispatcher import EventDispatcher
|
|
44
|
-
from .dispatch.prefix_dispatcher import PrefixDispatcher
|
|
45
|
-
from .dispatch.command_dispatcher import CommandDispatcher
|
|
46
|
-
|
|
47
|
-
self.token = token
|
|
48
|
-
self.application_id = application_id
|
|
49
|
-
self.config = config
|
|
50
|
-
|
|
51
|
-
self._logger = Logger(debug_mode, quiet)
|
|
52
|
-
self._ws = GatewayClient(token, intents, self._logger)
|
|
53
|
-
self._http = HTTPClient(token, self._logger)
|
|
54
|
-
|
|
55
|
-
if prefix and (intents & Intents.MESSAGE_CONTENT == 0):
|
|
56
|
-
self._logger.log_warn('Prefix set without message content enabled.')
|
|
57
|
-
|
|
58
|
-
self.dispatcher = EventDispatcher(self)
|
|
59
|
-
self.prefix_dispatcher = PrefixDispatcher(self, prefix)
|
|
60
|
-
self.command_dispatcher = CommandDispatcher(self)
|
|
61
|
-
|
|
62
|
-
self._global_commands = [] # SlashCommand
|
|
63
|
-
self._guild_commands = {} # {guild_id : [commands], ...}
|
|
64
|
-
|
|
65
|
-
self._is_set_up = False
|
|
66
|
-
self._setup_hooks = []
|
|
67
|
-
self._shutdown_hooks = []
|
|
68
|
-
|
|
69
|
-
self.emojis = BotEmojis(self._http, self.application_id)
|
|
70
|
-
|
|
71
|
-
def prefix_command(self, func):
|
|
72
|
-
"""Decorator registers prefix commands by the name of the function.
|
|
73
|
-
|
|
74
|
-
Args:
|
|
75
|
-
func (callable): callback handle for command response
|
|
76
|
-
"""
|
|
77
|
-
self.prefix_dispatcher.register(func.__name__, func)
|
|
78
|
-
|
|
79
|
-
def component(self, custom_id: str):
|
|
80
|
-
"""Decorator registers a function for a component handler.
|
|
81
|
-
|
|
82
|
-
Args:
|
|
83
|
-
custom_id (str): Identifier of the component
|
|
84
|
-
!!! warning "Important"
|
|
85
|
-
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 = None):
|
|
93
|
-
"""Decorator to register a function as a command handler.
|
|
94
|
-
|
|
95
|
-
Args:
|
|
96
|
-
command (SlashCommand | MessageCommand | UserCommand): The command to register.
|
|
97
|
-
guild_ids (list[int] | None): Guild IDs for guild-specific commands. None for global commands.
|
|
98
|
-
"""
|
|
99
|
-
def decorator(func):
|
|
100
|
-
# Map command types to dispatcher registration functions
|
|
101
|
-
handler_map = {
|
|
102
|
-
MessageCommand: self.command_dispatcher.message_command,
|
|
103
|
-
UserCommand: self.command_dispatcher.user_command,
|
|
104
|
-
SlashCommand: self.command_dispatcher.command,
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
# Resolve dispatcher method based on command type
|
|
108
|
-
for cls, handler in handler_map.items():
|
|
109
|
-
if isinstance(command, cls):
|
|
110
|
-
handler(command.name, func)
|
|
111
|
-
break
|
|
112
|
-
else:
|
|
113
|
-
raise ValueError(
|
|
114
|
-
f"Command {getattr(command, 'name', '<unnamed>')} must be one of "
|
|
115
|
-
f"SlashCommand, UserCommand, MessageCommand; got {type(command).__name__}."
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
# Queue command for later registration
|
|
119
|
-
if guild_ids:
|
|
120
|
-
gids = [guild_ids] if isinstance(guild_ids, int) else guild_ids
|
|
121
|
-
for gid in gids:
|
|
122
|
-
self._guild_commands.setdefault(gid, []).append(command)
|
|
123
|
-
else:
|
|
124
|
-
self._global_commands.append(command)
|
|
125
|
-
|
|
126
|
-
return func # ensure original function is preserved
|
|
127
|
-
return decorator
|
|
128
|
-
|
|
129
|
-
def event(self, event_name: str):
|
|
130
|
-
"""Decorator registers a function for an event handler.
|
|
131
|
-
|
|
132
|
-
Args:
|
|
133
|
-
event_name (str): event name (must be a valid event)
|
|
134
|
-
"""
|
|
135
|
-
def decorator(func):
|
|
136
|
-
self.dispatcher.register(event_name, func)
|
|
137
|
-
return func
|
|
138
|
-
return decorator
|
|
139
|
-
|
|
140
|
-
def setup_hook(self, func):
|
|
141
|
-
"""Decorator registers a setup hook.
|
|
142
|
-
(Runs once before the bot starts listening)
|
|
143
|
-
|
|
144
|
-
Args:
|
|
145
|
-
func (callable): callback to the setup function
|
|
146
|
-
"""
|
|
147
|
-
self._setup_hooks.append(func)
|
|
148
|
-
|
|
149
|
-
def shutdown_hook(self, func):
|
|
150
|
-
"""Decorator registers a shutdown hook.
|
|
151
|
-
(Runs once before the bot exits the loop)
|
|
152
|
-
|
|
153
|
-
Args:
|
|
154
|
-
func (callable): callback to the shutdown function
|
|
155
|
-
"""
|
|
156
|
-
self._shutdown_hooks.append(func)
|
|
157
|
-
|
|
158
|
-
def fetch_application(self, application_id: int):
|
|
159
|
-
"""Creates an interactable application resource.
|
|
160
|
-
|
|
161
|
-
Args:
|
|
162
|
-
application_id (int): id of target application
|
|
163
|
-
|
|
164
|
-
Returns:
|
|
165
|
-
(Application): the Application resource
|
|
166
|
-
"""
|
|
167
|
-
from .resources.application import Application
|
|
168
|
-
|
|
169
|
-
return Application(application_id, self._http)
|
|
170
|
-
|
|
171
|
-
def fetch_guild(self, guild_id: int):
|
|
172
|
-
"""Creates an interactable guild resource.
|
|
173
|
-
|
|
174
|
-
Args:
|
|
175
|
-
guild_id (int): id of target guild
|
|
176
|
-
|
|
177
|
-
Returns:
|
|
178
|
-
(Guild): the Guild resource
|
|
179
|
-
"""
|
|
180
|
-
from .resources.guild import Guild
|
|
181
|
-
|
|
182
|
-
return Guild(guild_id, self._http)
|
|
183
|
-
|
|
184
|
-
def fetch_channel(self, channel_id: int):
|
|
185
|
-
"""Creates an interactable channel resource.
|
|
186
|
-
|
|
187
|
-
Args:
|
|
188
|
-
channel_id (int): id of target channel
|
|
189
|
-
|
|
190
|
-
Returns:
|
|
191
|
-
(Channel): the Channel resource
|
|
192
|
-
"""
|
|
193
|
-
from .resources.channel import Channel
|
|
194
|
-
|
|
195
|
-
return Channel(channel_id, self._http)
|
|
196
|
-
|
|
197
|
-
def fetch_message(self, channel_id: int, message_id: int):
|
|
198
|
-
"""Creates an interactable message resource.
|
|
199
|
-
|
|
200
|
-
Args:
|
|
201
|
-
message_id (int): id of target message
|
|
202
|
-
channel_id (int): channel id of target message
|
|
203
|
-
|
|
204
|
-
Returns:
|
|
205
|
-
(Message): the Message resource
|
|
206
|
-
"""
|
|
207
|
-
from .resources.message import Message
|
|
208
|
-
|
|
209
|
-
return Message(message_id, channel_id, self._http)
|
|
210
|
-
|
|
211
|
-
def fetch_user(self, user_id: int):
|
|
212
|
-
"""Creates an interactable user resource.
|
|
213
|
-
|
|
214
|
-
Args:
|
|
215
|
-
user_id (int): id of target user
|
|
216
|
-
|
|
217
|
-
Returns:
|
|
218
|
-
(User): the User resource
|
|
219
|
-
"""
|
|
220
|
-
from .resources.user import User
|
|
221
|
-
|
|
222
|
-
return User(user_id, self._http)
|
|
223
|
-
|
|
224
|
-
async def clear_guild_commands(self, guild_id: int):
|
|
225
|
-
"""Clear a guild's slash commands.
|
|
226
|
-
|
|
227
|
-
Args:
|
|
228
|
-
guild_id (int): id of the target guild
|
|
229
|
-
"""
|
|
230
|
-
if self._guild_commands.get(guild_id):
|
|
231
|
-
self._logger.log_info(f"Guild {guild_id} already queued, skipping clear.")
|
|
232
|
-
return
|
|
233
|
-
|
|
234
|
-
self._guild_commands[guild_id] = []
|
|
235
|
-
|
|
236
|
-
async def _listen(self):
|
|
237
|
-
"""Main event loop for incoming gateway requests."""
|
|
238
|
-
while self._ws.is_connected():
|
|
239
|
-
try:
|
|
240
|
-
message = await self._ws.receive()
|
|
241
|
-
if not message:
|
|
242
|
-
raise ConnectionError("No message received.")
|
|
243
|
-
|
|
244
|
-
op_code = message.get('op')
|
|
245
|
-
|
|
246
|
-
match op_code:
|
|
247
|
-
case 0:
|
|
248
|
-
dispatch_type = message.get('t')
|
|
249
|
-
self._logger.log_info(f"DISPATCH -> {dispatch_type}")
|
|
250
|
-
event_data = message.get('d')
|
|
251
|
-
self._ws.sequence = message.get('s') or self._ws.sequence
|
|
252
|
-
|
|
253
|
-
if dispatch_type == "READY":
|
|
254
|
-
self._ws.session_id = event_data.get("session_id")
|
|
255
|
-
self._ws.connect_url = event_data.get("resume_gateway_url", self._ws.connect_url)
|
|
256
|
-
|
|
257
|
-
if self.prefix_dispatcher.prefix and dispatch_type == 'MESSAGE_CREATE':
|
|
258
|
-
await self.prefix_dispatcher.dispatch(event_data)
|
|
259
|
-
|
|
260
|
-
elif dispatch_type == 'INTERACTION_CREATE':
|
|
261
|
-
await self.command_dispatcher.dispatch(event_data)
|
|
262
|
-
|
|
263
|
-
await self.dispatcher.dispatch(dispatch_type, event_data)
|
|
264
|
-
case 7:
|
|
265
|
-
raise ConnectionError("Reconnect requested by server.")
|
|
266
|
-
case 9:
|
|
267
|
-
self._ws.session_id = None
|
|
268
|
-
self._ws.sequence = None
|
|
269
|
-
raise ConnectionError("Invalid session.")
|
|
270
|
-
case 11:
|
|
271
|
-
self._logger.log_debug("Heartbeat ACK received")
|
|
272
|
-
|
|
273
|
-
except asyncio.CancelledError:
|
|
274
|
-
break
|
|
275
|
-
except DiscordError as e:
|
|
276
|
-
if e.fatal:
|
|
277
|
-
self._logger.log_error(f"Fatal DiscordError: {e}")
|
|
278
|
-
break
|
|
279
|
-
else:
|
|
280
|
-
self._logger.log_warn(f"Recoverable DiscordError: {e}")
|
|
281
|
-
except ConnectionError as e:
|
|
282
|
-
self._logger.log_warn(f"Connection lost: {e}")
|
|
283
|
-
raise
|
|
284
|
-
except Exception as e:
|
|
285
|
-
self._logger.log_error(f"{type(e).__name__} - {e}")
|
|
286
|
-
continue
|
|
287
|
-
|
|
288
|
-
async def start(self):
|
|
289
|
-
"""Runs the main lifecycle of the bot.
|
|
290
|
-
Handles connection setup, heartbeat management, event loop, and automatic reconnects.
|
|
291
|
-
"""
|
|
292
|
-
try:
|
|
293
|
-
await self._http.start_session()
|
|
294
|
-
await self._ws.connect()
|
|
295
|
-
await self._ws.start_heartbeat()
|
|
296
|
-
|
|
297
|
-
while self._ws.is_connected():
|
|
298
|
-
if self._ws.session_id and self._ws.sequence:
|
|
299
|
-
await self._ws.reconnect()
|
|
300
|
-
else:
|
|
301
|
-
await self._ws.identify()
|
|
302
|
-
|
|
303
|
-
if not self._is_set_up:
|
|
304
|
-
await self._startup()
|
|
305
|
-
self._is_set_up = True
|
|
306
|
-
|
|
307
|
-
await self._listen()
|
|
308
|
-
|
|
309
|
-
# If we get here, connection was lost - reconnect
|
|
310
|
-
await self._ws.close()
|
|
311
|
-
await asyncio.sleep(5)
|
|
312
|
-
await self._ws.connect()
|
|
313
|
-
await self._ws.start_heartbeat()
|
|
314
|
-
|
|
315
|
-
except asyncio.CancelledError:
|
|
316
|
-
self._logger.log_high_priority("Connection cancelled via KeyboardInterrupt.")
|
|
317
|
-
except Exception as e:
|
|
318
|
-
self._logger.log_error(f"{type(e).__name__} - {e}")
|
|
319
|
-
finally:
|
|
320
|
-
await self._close()
|
|
321
|
-
|
|
322
|
-
async def _startup(self):
|
|
323
|
-
"""Start up bot by registering user-defined hooks and commands."""
|
|
324
|
-
try:
|
|
325
|
-
if self._setup_hooks:
|
|
326
|
-
for hook in self._setup_hooks:
|
|
327
|
-
self._logger.log_info(f"Setting hook {hook.__name__}")
|
|
328
|
-
await hook(self)
|
|
329
|
-
self._logger.log_high_priority("Hooks set up.")
|
|
330
|
-
|
|
331
|
-
# register GUILD commands
|
|
332
|
-
await self.command_dispatcher._register_guild_commands(self._guild_commands)
|
|
333
|
-
|
|
334
|
-
# register GLOBAL commands
|
|
335
|
-
await self.command_dispatcher._register_global_commands(self._global_commands)
|
|
336
|
-
|
|
337
|
-
self._logger.log_high_priority("Commands set up.")
|
|
338
|
-
except Exception:
|
|
339
|
-
raise
|
|
340
|
-
|
|
341
|
-
async def _close(self):
|
|
342
|
-
"""Gracefully close HTTP and websocket connections."""
|
|
343
|
-
# Run shutdown hooks first
|
|
344
|
-
for hook in self._shutdown_hooks:
|
|
345
|
-
try:
|
|
346
|
-
self._logger.log_info(f"Executing shutdown hook {hook.__name__}")
|
|
347
|
-
await hook(self)
|
|
348
|
-
except Exception as e:
|
|
349
|
-
self._logger.log_error(f"Shutdown hook failed: {type(e).__name__}: {e}")
|
|
350
|
-
|
|
351
|
-
# Close HTTP before gateway since it's more important
|
|
352
|
-
self._logger.log_debug("Closing HTTP session...")
|
|
353
|
-
await self._http.close_session()
|
|
354
|
-
|
|
355
|
-
# Then try websocket with short timeout
|
|
356
|
-
try:
|
|
357
|
-
self._logger.log_debug("Closing websocket connection...")
|
|
358
|
-
await asyncio.wait_for(self._ws.close(), timeout=1.0)
|
|
359
|
-
except:
|
|
360
|
-
pass # Don't care if websocket won't close
|
|
361
|
-
|
|
362
|
-
def run(self):
|
|
363
|
-
"""Starts the bot.
|
|
364
|
-
Handles starting the session, WS, and heartbeat, reconnection logic,
|
|
365
|
-
setting up emojis and hooks, and then listens for gateway events.
|
|
366
|
-
"""
|
|
367
|
-
try:
|
|
368
|
-
asyncio.run(self.start())
|
|
369
|
-
except KeyboardInterrupt:
|
|
370
|
-
self._logger.log_debug("Shutdown requested via KeyboardInterrupt.")
|
|
371
|
-
except Exception as e:
|
|
372
|
-
self._logger.log_error(f"{type(e).__name__} {e}")
|
|
373
|
-
finally:
|
|
374
|
-
self._logger.log_high_priority("Bot shutting down.")
|
|
375
|
-
self._logger.close()
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
from ..client_like import ClientLike
|
|
2
|
-
|
|
3
|
-
from ..events.interaction_events import ApplicationCommandData, MessageComponentData, ModalData, InteractionEvent
|
|
4
|
-
from ..resources.interaction import Interaction, InteractionDataTypes
|
|
5
|
-
|
|
6
|
-
class InteractionTypes:
|
|
7
|
-
"""Interaction types constants."""
|
|
8
|
-
|
|
9
|
-
APPLICATION_COMMAND = 2
|
|
10
|
-
"""Slash command interaction."""
|
|
11
|
-
|
|
12
|
-
MESSAGE_COMPONENT = 3
|
|
13
|
-
"""Message component interaction (e.g., button, select menu, etc.)."""
|
|
14
|
-
|
|
15
|
-
MODAL_SUBMIT = 5
|
|
16
|
-
"""Modal submit interaction."""
|
|
17
|
-
|
|
18
|
-
class CommandDispatcher:
|
|
19
|
-
"""Central hub for registering and dispatching interaction responses."""
|
|
20
|
-
|
|
21
|
-
RESOURCE_MAP = { # maps discord events to their respective dataclass
|
|
22
|
-
InteractionTypes.APPLICATION_COMMAND: ApplicationCommandData,
|
|
23
|
-
InteractionTypes.MESSAGE_COMPONENT: MessageComponentData,
|
|
24
|
-
InteractionTypes.MODAL_SUBMIT: ModalData
|
|
25
|
-
}
|
|
26
|
-
"""Maps [`InteractionTypes`][discord.dispatch.command_dispatcher.InteractionTypes] to their respective dataclass."""
|
|
27
|
-
|
|
28
|
-
def __init__(self, client: ClientLike):
|
|
29
|
-
self.application_id = client.application_id
|
|
30
|
-
"""Bot's application ID."""
|
|
31
|
-
|
|
32
|
-
self.bot = client
|
|
33
|
-
"""Bot session for user access to bot."""
|
|
34
|
-
|
|
35
|
-
self._http = client._http
|
|
36
|
-
"""HTTP session for requests."""
|
|
37
|
-
|
|
38
|
-
self._logger = client._logger
|
|
39
|
-
"""Logger instance to log events."""
|
|
40
|
-
|
|
41
|
-
self.config = client.config
|
|
42
|
-
|
|
43
|
-
self._component_handlers = {}
|
|
44
|
-
"""Mapping of component custom IDs to handler."""
|
|
45
|
-
|
|
46
|
-
self._handlers = {}
|
|
47
|
-
"""Mapping of command names to handler."""
|
|
48
|
-
|
|
49
|
-
self._message_handlers = {}
|
|
50
|
-
"""Mapping of message command names to handler."""
|
|
51
|
-
|
|
52
|
-
self._user_handlers = {}
|
|
53
|
-
"""Mapping of user command names to handler."""
|
|
54
|
-
|
|
55
|
-
async def _register_guild_commands(self, commands: dict):
|
|
56
|
-
"""Registers a command at the guild level.
|
|
57
|
-
|
|
58
|
-
Args:
|
|
59
|
-
commands (dict): mapping of guild IDs to respective serialized command data
|
|
60
|
-
"""
|
|
61
|
-
|
|
62
|
-
for guild_id, cmds in commands.items():
|
|
63
|
-
# register commands PER GUILD
|
|
64
|
-
await self._http.request(
|
|
65
|
-
'PUT',
|
|
66
|
-
f"applications/{self.application_id}/guilds/{guild_id}/commands",
|
|
67
|
-
[command._to_dict() for command in cmds]
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
async def _register_global_commands(self, commands: list):
|
|
71
|
-
"""Registers a command at the global/bot level. (ALL GUILDS)
|
|
72
|
-
|
|
73
|
-
Args:
|
|
74
|
-
commands (list): list of serialized commands
|
|
75
|
-
"""
|
|
76
|
-
|
|
77
|
-
global_commands = [command._to_dict() for command in commands]
|
|
78
|
-
|
|
79
|
-
await self._http.request('PUT', f"applications/{self.application_id}/commands", global_commands)
|
|
80
|
-
|
|
81
|
-
def command(self, name: str, handler):
|
|
82
|
-
"""Decorator to register slash commands.
|
|
83
|
-
|
|
84
|
-
Args:
|
|
85
|
-
name (str): name of the command to register
|
|
86
|
-
handler (callable): callback handle for command response
|
|
87
|
-
"""
|
|
88
|
-
self._handlers[name] = handler
|
|
89
|
-
|
|
90
|
-
def user_command(self, name: str, handler):
|
|
91
|
-
"""Decorator to register user commands.
|
|
92
|
-
|
|
93
|
-
Args:
|
|
94
|
-
name (str): name of the command to register
|
|
95
|
-
handler (callable): callback handle for user command response
|
|
96
|
-
"""
|
|
97
|
-
self._user_handlers[name] = handler
|
|
98
|
-
|
|
99
|
-
def message_command(self, name: str, handler):
|
|
100
|
-
"""Decorator to register message commands.
|
|
101
|
-
|
|
102
|
-
Args:
|
|
103
|
-
name (str): name of the command to register
|
|
104
|
-
handler (callable): callback handle for message command response
|
|
105
|
-
"""
|
|
106
|
-
self._message_handlers[name] = handler
|
|
107
|
-
|
|
108
|
-
def component(self, func, custom_id: str):
|
|
109
|
-
"""Decorator to register component interactions.
|
|
110
|
-
|
|
111
|
-
Args:
|
|
112
|
-
custom_id (str): Identifier of the component
|
|
113
|
-
!!! warning "Important"
|
|
114
|
-
Must match the `custom_id` set where the component was created.
|
|
115
|
-
"""
|
|
116
|
-
self._component_handlers[custom_id] = func
|
|
117
|
-
|
|
118
|
-
async def dispatch(self, data: dict):
|
|
119
|
-
"""Dispatch a response to an `INTERACTION_CREATE` event
|
|
120
|
-
|
|
121
|
-
Args:
|
|
122
|
-
data (dict): interaction data
|
|
123
|
-
"""
|
|
124
|
-
event = InteractionEvent(interaction=Interaction.from_dict(data, self._http))
|
|
125
|
-
|
|
126
|
-
event_data_obj = self.RESOURCE_MAP.get(event.interaction.type)
|
|
127
|
-
|
|
128
|
-
if not event_data_obj:
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
event.data = event_data_obj.from_dict(data.get('data'))
|
|
132
|
-
handler = None
|
|
133
|
-
name = None
|
|
134
|
-
|
|
135
|
-
match event.interaction.type:
|
|
136
|
-
case InteractionTypes.APPLICATION_COMMAND:
|
|
137
|
-
name = event.data.name
|
|
138
|
-
|
|
139
|
-
match event.data.type:
|
|
140
|
-
case InteractionDataTypes.SLASH_COMMAND:
|
|
141
|
-
handler = self._handlers.get(name)
|
|
142
|
-
case InteractionDataTypes.USER_COMMAND:
|
|
143
|
-
handler = self._user_handlers.get(name)
|
|
144
|
-
case InteractionDataTypes.MESSAGE_COMMAND:
|
|
145
|
-
handler = self._message_handlers.get(name)
|
|
146
|
-
|
|
147
|
-
case InteractionTypes.MESSAGE_COMPONENT:
|
|
148
|
-
name = event.data.custom_id
|
|
149
|
-
handler = self._component_handlers.get(name)
|
|
150
|
-
|
|
151
|
-
case InteractionTypes.MODAL_SUBMIT:
|
|
152
|
-
name = event.data.custom_id
|
|
153
|
-
handler = self._component_handlers.get(name)
|
|
154
|
-
|
|
155
|
-
if not handler:
|
|
156
|
-
self._logger.log_warn(f"No handler registered for interaction '{name}'")
|
|
157
|
-
return
|
|
158
|
-
|
|
159
|
-
try:
|
|
160
|
-
await handler(self.bot, event) # NOTE: treat command options as args!
|
|
161
|
-
self._logger.log_info(f"Interaction Event '{name}' Acknowledged.")
|
|
162
|
-
except Exception as e:
|
|
163
|
-
self._logger.log_error(f"Error in interaction '{name}': {e}")
|
discord/gateway.py
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import json
|
|
3
|
-
import websockets
|
|
4
|
-
|
|
5
|
-
from .logger import Logger
|
|
6
|
-
from .events.hello_event import HelloEvent
|
|
7
|
-
|
|
8
|
-
DISCORD_OP_CODES = {
|
|
9
|
-
0: "Dispatch",
|
|
10
|
-
1: "Heartbeat",
|
|
11
|
-
2: "Identify",
|
|
12
|
-
3: "Presence Update",
|
|
13
|
-
6: "Resume",
|
|
14
|
-
7: "Reconnect",
|
|
15
|
-
8: "Request Guild Members",
|
|
16
|
-
9: "Invalid Session",
|
|
17
|
-
10: "Hello",
|
|
18
|
-
11: "Heartbeat ACK",
|
|
19
|
-
None: "Unknown"
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
class GatewayClient:
|
|
23
|
-
"""Handles real-time Websocket (WS) connection to Discord's Gateway.
|
|
24
|
-
Connects to Discord's WS, handles identify/resume logic, and maintains heartbeat.
|
|
25
|
-
"""
|
|
26
|
-
def __init__(self, token: str, intents: int, logger: Logger):
|
|
27
|
-
self.token = token
|
|
28
|
-
"""The bot's token."""
|
|
29
|
-
|
|
30
|
-
self._logger = logger
|
|
31
|
-
"""Logger instance to log events."""
|
|
32
|
-
|
|
33
|
-
self.ws = None
|
|
34
|
-
"""Websocket instance."""
|
|
35
|
-
|
|
36
|
-
self.heartbeat = None
|
|
37
|
-
"""Heartbeat task instance."""
|
|
38
|
-
|
|
39
|
-
self.sequence = None
|
|
40
|
-
"""Discord-generated sequence number for this websocket connection."""
|
|
41
|
-
|
|
42
|
-
self.session_id = None
|
|
43
|
-
"""Discord-generated session ID for this websocket connection."""
|
|
44
|
-
|
|
45
|
-
self.intents = intents
|
|
46
|
-
"""User-defined bot intents (for identify)."""
|
|
47
|
-
|
|
48
|
-
self.url_params = "?v=10&encoding=json"
|
|
49
|
-
"""Discord WS query params."""
|
|
50
|
-
|
|
51
|
-
self.connect_url = f"wss://gateway.discord.gg/"
|
|
52
|
-
"""URL to connect to Discord's gateway."""
|
|
53
|
-
|
|
54
|
-
async def connect(self):
|
|
55
|
-
"""Established websocket connection to Discord."""
|
|
56
|
-
self.ws = await websockets.connect(self.connect_url + self.url_params)
|
|
57
|
-
self._logger.log_high_priority("Connected to Discord.")
|
|
58
|
-
|
|
59
|
-
async def receive(self):
|
|
60
|
-
"""Receives and logs messages from the gateway.
|
|
61
|
-
|
|
62
|
-
Returns:
|
|
63
|
-
(dict): parsed JSON data
|
|
64
|
-
"""
|
|
65
|
-
|
|
66
|
-
message = await asyncio.wait_for(self.ws.recv(), timeout=60)
|
|
67
|
-
|
|
68
|
-
if message:
|
|
69
|
-
data: dict = json.loads(message)
|
|
70
|
-
self._logger.log_debug(f"Received: {DISCORD_OP_CODES.get(data.get('op'))} - {json.dumps(data, indent=4)}")
|
|
71
|
-
self._logger.log_info(f"Received: {DISCORD_OP_CODES.get(data.get('op'))}")
|
|
72
|
-
return data
|
|
73
|
-
|
|
74
|
-
return None
|
|
75
|
-
|
|
76
|
-
async def send(self, message: dict):
|
|
77
|
-
"""Sends a JSON-encoded message to the gateway.
|
|
78
|
-
|
|
79
|
-
Args:
|
|
80
|
-
message (dict): the message to send
|
|
81
|
-
"""
|
|
82
|
-
self._logger.log_debug(f"Sending payload: {message}")
|
|
83
|
-
await self.ws.send(json.dumps(message))
|
|
84
|
-
|
|
85
|
-
async def send_heartbeat_loop(self):
|
|
86
|
-
"""Background task that sends heartbeat payloads in regular intervals.
|
|
87
|
-
Retries until cancelled.
|
|
88
|
-
"""
|
|
89
|
-
while self.ws:
|
|
90
|
-
await asyncio.sleep(self.heartbeat_interval / 1000)
|
|
91
|
-
hb_data = {"op": 1, "d": self.sequence}
|
|
92
|
-
await self.send(hb_data)
|
|
93
|
-
self._logger.log_debug(f"Sending: {hb_data}")
|
|
94
|
-
self._logger.log_info("Heartbeat sent.")
|
|
95
|
-
|
|
96
|
-
async def identify(self):
|
|
97
|
-
"""Sends the IDENIFY payload (token, intents, connection properties).
|
|
98
|
-
Must be sent after connecting to the WS.
|
|
99
|
-
"""
|
|
100
|
-
i = {
|
|
101
|
-
"op": 2,
|
|
102
|
-
"d": {
|
|
103
|
-
"token": f"Bot {self.token}",
|
|
104
|
-
"intents": self.intents,
|
|
105
|
-
"properties": {
|
|
106
|
-
"$os": "my_os",
|
|
107
|
-
"$browser": "my_bot",
|
|
108
|
-
"$device": "my_bot"
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
await self.send(i)
|
|
113
|
-
log_i = self._logger.redact(i)
|
|
114
|
-
self._logger.log_debug(f"Sending: {log_i}")
|
|
115
|
-
self._logger.log_high_priority("Identify sent.")
|
|
116
|
-
|
|
117
|
-
async def start_heartbeat(self):
|
|
118
|
-
"""Waits for initial HELLO event, hydrates the HelloEvent class, and begins the heartbeat."""
|
|
119
|
-
data = await self.receive()
|
|
120
|
-
hello = HelloEvent.from_dict(data.get('d'))
|
|
121
|
-
self.heartbeat_interval = hello.heartbeat_interval
|
|
122
|
-
self.heartbeat = asyncio.create_task(self.send_heartbeat_loop())
|
|
123
|
-
self._logger.log_high_priority("Heartbeat started.")
|
|
124
|
-
|
|
125
|
-
async def reconnect(self):
|
|
126
|
-
"""Sends RESUME payload to reconnect with the same session ID and sequence number
|
|
127
|
-
as provided by Discord.
|
|
128
|
-
"""
|
|
129
|
-
await self.send({
|
|
130
|
-
"op": 6,
|
|
131
|
-
"d": {
|
|
132
|
-
"token": f"Bot {self.token}",
|
|
133
|
-
"session_id": self.session_id,
|
|
134
|
-
"seq": self.sequence
|
|
135
|
-
}
|
|
136
|
-
})
|
|
137
|
-
self._logger.log_high_priority("RESUME sent")
|
|
138
|
-
|
|
139
|
-
async def close(self):
|
|
140
|
-
"""Cancels heart beat and cleanly closes WS with error handling."""
|
|
141
|
-
# Cancel heartbeat task if it's still running
|
|
142
|
-
if self.heartbeat:
|
|
143
|
-
self._logger.log_high_priority(f"Cancelling heartbeat...")
|
|
144
|
-
self.heartbeat.cancel()
|
|
145
|
-
await asyncio.wait_for(self.heartbeat, timeout=3) # Add timeout to avoid hanging
|
|
146
|
-
self.heartbeat = None
|
|
147
|
-
|
|
148
|
-
if self.ws:
|
|
149
|
-
await self.ws.close()
|
|
150
|
-
self._logger.log_high_priority("WebSocket closed.")
|
|
151
|
-
self.ws = None
|
|
152
|
-
|
|
153
|
-
def is_connected(self):
|
|
154
|
-
"""Helper function to tell whether the websocket is still active."""
|
|
155
|
-
return self.ws is not None
|