scurrypy 0.3.2__py3-none-any.whl → 0.3.4.1__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.

Potentially problematic release.


This version of scurrypy might be problematic. Click here for more details.

discord/__init__.py CHANGED
@@ -1,10 +1,223 @@
1
1
  # discord
2
2
 
3
- from .logger import Logger
4
- from .client import Client
5
- from .intents import Intents, set_intents
6
- from .config import BaseConfig
7
-
8
- from .events import *
9
- from .resources import *
10
- from .parts import *
3
+ import importlib
4
+ from typing import TYPE_CHECKING
5
+
6
+ __all__ = [
7
+ # top-level
8
+ "Logger",
9
+ "Client",
10
+ "Intents",
11
+ "set_intents",
12
+ "BaseConfig",
13
+
14
+ # events
15
+ "ReadyEvent",
16
+ "ReactionAddEvent",
17
+ "ReactionRemoveEvent",
18
+ "ReactionRemoveEmojiEvent",
19
+ "ReactionRemoveAllEvent",
20
+ "GuildCreateEvent",
21
+ "GuildUpdateEvent",
22
+ "GuildDeleteEvent",
23
+ "MessageCreateEvent",
24
+ "MessageUpdateEvent",
25
+ "MessageDeleteEvent",
26
+ "GuildChannelCreateEvent",
27
+ "GuildChannelUpdateEvent",
28
+ "GuildChannelDeleteEvent",
29
+ "ChannelPinsUpdateEvent",
30
+ "InteractionEvent",
31
+
32
+ # models
33
+ "ApplicationModel",
34
+ "EmojiModel",
35
+ "GuildModel",
36
+ "MemberModel",
37
+ "UserModel",
38
+ "RoleModel",
39
+
40
+ # parts
41
+ "GuildChannel",
42
+ "Role",
43
+ "MessageBuilder",
44
+ "ModalBuilder",
45
+ "EmbedBuilder",
46
+ "ActionRow",
47
+ "StringSelect",
48
+ "UserSelect",
49
+ "RoleSelect",
50
+ "ChannelSelect",
51
+ "MentionableSelect",
52
+ "SlashCommand",
53
+ "MessageCommand",
54
+ "UserCommand",
55
+ "Container",
56
+ "Section",
57
+ "MediaGalleryItem",
58
+ "Label",
59
+
60
+ # resources
61
+ "Guild",
62
+ "Channel",
63
+ "Message",
64
+ "BotEmojis",
65
+ "User",
66
+ "Interaction",
67
+ "Application",
68
+ ]
69
+
70
+ # For editor support / autocomplete
71
+ if TYPE_CHECKING:
72
+ from .logger import Logger
73
+ from .client import Client
74
+ from .intents import Intents, set_intents
75
+ from .config import BaseConfig
76
+
77
+ # events
78
+ from .events.ready_event import ReadyEvent
79
+ from .events.reaction_events import (
80
+ ReactionAddEvent,
81
+ ReactionRemoveEvent,
82
+ ReactionRemoveEmojiEvent,
83
+ ReactionRemoveAllEvent,
84
+ )
85
+ from .events.guild_events import (
86
+ GuildCreateEvent,
87
+ GuildUpdateEvent,
88
+ GuildDeleteEvent,
89
+ )
90
+ from .events.message_events import (
91
+ MessageCreateEvent,
92
+ MessageUpdateEvent,
93
+ MessageDeleteEvent,
94
+ )
95
+ from .events.channel_events import (
96
+ GuildChannelCreateEvent,
97
+ GuildChannelUpdateEvent,
98
+ GuildChannelDeleteEvent,
99
+ ChannelPinsUpdateEvent,
100
+ )
101
+ from .events.interaction_events import InteractionEvent
102
+
103
+ # models
104
+ from .models.application import ApplicationModel
105
+ from .models.emoji import EmojiModel
106
+ from .models.guild import GuildModel
107
+ from .models.member import MemberModel
108
+ from .models.user import UserModel
109
+ from .models.role import RoleModel
110
+
111
+ # parts
112
+ from .parts.channel import GuildChannel
113
+ from .parts.role import Role
114
+ from .parts.message import MessageBuilder
115
+ from .parts.modal import ModalBuilder
116
+ from .parts.embed import EmbedBuilder
117
+ from .parts.action_row import (
118
+ ActionRow,
119
+ StringSelect,
120
+ UserSelect,
121
+ RoleSelect,
122
+ ChannelSelect,
123
+ MentionableSelect
124
+ )
125
+
126
+ from .parts.command import (
127
+ SlashCommand,
128
+ MessageCommand,
129
+ UserCommand
130
+ )
131
+
132
+ from .parts.components_v2 import (
133
+ Container,
134
+ Section,
135
+ MediaGalleryItem,
136
+ Label
137
+ )
138
+
139
+ # resources
140
+ from .resources.guild import Guild
141
+ from .resources.channel import Channel
142
+ from .resources.message import Message
143
+ from .resources.bot_emojis import BotEmojis
144
+ from .resources.user import User
145
+ from .resources.interaction import Interaction
146
+ from .resources.application import Application
147
+
148
+ # Lazy loader
149
+ def __getattr__(name: str):
150
+ if name not in __all__:
151
+ raise AttributeError(f"module {__name__} has no attribute {name}")
152
+
153
+ mapping = {
154
+ # top-level
155
+ "Logger": "discord.logger",
156
+ "Client": "discord.client",
157
+ "Intents": "discord.intents",
158
+ "set_intents": "discord.intents",
159
+ "BaseConfig": "discord.config",
160
+
161
+ # events
162
+ "ReadyEvent": "discord.events.ready_event",
163
+ "ReactionAddEvent": "discord.events.reaction_events",
164
+ "ReactionRemoveEvent": "discord.events.reaction_events",
165
+ "ReactionRemoveEmojiEvent": "discord.events.reaction_events",
166
+ "ReactionRemoveAllEvent": "discord.events.reaction_events",
167
+ "GuildCreateEvent": "discord.events.guild_events",
168
+ "GuildUpdateEvent": "discord.events.guild_events",
169
+ "GuildDeleteEvent": "discord.events.guild_events",
170
+ "MessageCreateEvent": "discord.events.message_events",
171
+ "MessageUpdateEvent": "discord.events.message_events",
172
+ "MessageDeleteEvent": "discord.events.message_events",
173
+ "GuildChannelCreateEvent": "discord.events.channel_events",
174
+ "GuildChannelUpdateEvent": "discord.events.channel_events",
175
+ "GuildChannelDeleteEvent": "discord.events.channel_events",
176
+ "ChannelPinsUpdateEvent": "discord.events.channel_events",
177
+ "InteractionEvent": "discord.events.interaction_events",
178
+
179
+ # models
180
+ 'ApplicationModel': "discord.models.application",
181
+ 'EmojiModel': "discord.models.emoji",
182
+ 'GuildModel': "discord.models.guild",
183
+ 'MemberModel': "discord.models.member",
184
+ 'UserModel': "discord.models.user",
185
+ 'RoleModel': "discord.models.role",
186
+
187
+ # parts
188
+ 'GuildChannel': "discord.parts.channel",
189
+ 'Role': "discord.parts.role",
190
+ 'MessageBuilder': "discord.parts.message",
191
+ 'ModalBuilder': "discord.parts.modal",
192
+ 'EmbedBuilder': "discord.parts.embed",
193
+ 'ActionRow': "discord.parts.action_row",
194
+ 'StringSelect': "discord.parts.action_row",
195
+ 'UserSelect': "discord.parts.action_row",
196
+ 'RoleSelect': "discord.parts.action_row",
197
+ 'ChannelSelect': "discord.parts.action_row",
198
+ 'MentionableSelect': "discord.parts.action_row",
199
+ 'SlashCommand': "discord.parts.command",
200
+ 'MessageCommand': "discord.parts.command",
201
+ 'UserCommand': "discord.parts.command",
202
+ 'Container': "discord.parts.components_v2",
203
+ 'Section': "discord.parts.components_v2",
204
+ 'MediaGalleryItem': "discord.parts.components_v2",
205
+ 'Label': "discord.parts.components_v2",
206
+
207
+ # resources
208
+ 'Guild': "discord.resources.guild",
209
+ 'Channel': "discord.resources.channel",
210
+ 'Message': "discord.resources.message",
211
+ 'BotEmojis': "discord.resources.bot_emojis",
212
+ 'User': "discord.resources.user",
213
+ 'Interaction': "discord.resources.interaction",
214
+ 'Application': "discord.resources.application"
215
+ }
216
+
217
+ module = importlib.import_module(mapping[name])
218
+ attr = getattr(module, name)
219
+ globals()[name] = attr # cache it for future lookups
220
+ return attr
221
+
222
+ def __dir__():
223
+ return sorted(list(globals().keys()) + __all__)
discord/client.py CHANGED
@@ -1,26 +1,12 @@
1
1
  import asyncio
2
2
 
3
- from .logger import Logger
4
- from .gateway import GatewayClient
5
- from .http import HTTPClient
3
+ from .config import BaseConfig
6
4
  from .intents import Intents
7
5
  from .error import DiscordError
8
- from .config import BaseConfig
9
6
  from .client_like import ClientLike
10
7
 
11
- from .resources.guild import Guild
12
- from .resources.channel import Channel
13
- from .resources.message import Message
14
- from .resources.bot_emojis import BotEmojis
15
- from .resources.user import User
16
- from .resources.application import Application
17
-
18
8
  from .parts.command import SlashCommand, MessageCommand, UserCommand
19
9
 
20
- from .dispatch.event_dispatcher import EventDispatcher
21
- from .dispatch.prefix_dispatcher import PrefixDispatcher
22
- from .dispatch.command_dispatcher import CommandDispatcher
23
-
24
10
  class Client(ClientLike):
25
11
  """Main entry point for Discord bots.
26
12
  Ties together the moving parts: gateway, HTTP, event dispatching, command handling, and resource managers.
@@ -45,6 +31,19 @@ class Client(ClientLike):
45
31
  prefix (str, optional): set message prefix if using command prefixes
46
32
  quiet (bool, optional): if INFO, DEBUG, and WARN should be logged
47
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
+
48
47
  self.token = token
49
48
  self.application_id = application_id
50
49
  self.config = config
@@ -90,30 +89,41 @@ class Client(ClientLike):
90
89
  return func
91
90
  return decorator
92
91
 
93
- def command(self, command: SlashCommand | MessageCommand | UserCommand, guild_id: int = 0):
94
- """Decorator registers a function for a command handler.
92
+ def command(self, command: SlashCommand | MessageCommand | UserCommand, guild_ids: list[int] | None = None):
93
+ """Decorator to register a function as a command handler.
95
94
 
96
95
  Args:
97
- command (SlashCommand | MessageCommand | UserCommand): command to register
98
- guild_id (int): ID of guild in which to register command (if a guild command)
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.
99
98
  """
100
99
  def decorator(func):
101
- # hash out command type
102
- if isinstance(command, MessageCommand):
103
- self.command_dispatcher.message_command(command.name, func)
104
- elif isinstance(command, UserCommand):
105
- self.command_dispatcher.user_command(command.name, func)
106
- elif isinstance(command, SlashCommand):
107
- self.command_dispatcher.command(command.name, 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
108
112
  else:
109
- raise ValueError(f'Command {command.name} expected to be of type SlashCommand, UserCommand, MessageCommand; \
110
- got {type(command).__name__}.')
111
-
112
- # then hash out if this command should be guild or global level
113
- if guild_id != 0:
114
- self._guild_commands.setdefault(guild_id, []).append(command)
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)
115
123
  else:
116
124
  self._global_commands.append(command)
125
+
126
+ return func # ensure original function is preserved
117
127
  return decorator
118
128
 
119
129
  def event(self, event_name: str):
@@ -145,7 +155,7 @@ class Client(ClientLike):
145
155
  """
146
156
  self._shutdown_hooks.append(func)
147
157
 
148
- def application_from_id(self, application_id: int):
158
+ def fetch_application(self, application_id: int):
149
159
  """Creates an interactable application resource.
150
160
 
151
161
  Args:
@@ -154,9 +164,11 @@ class Client(ClientLike):
154
164
  Returns:
155
165
  (Application): the Application resource
156
166
  """
167
+ from .resources.application import Application
168
+
157
169
  return Application(application_id, self._http)
158
170
 
159
- def guild_from_id(self, guild_id: int):
171
+ def fetch_guild(self, guild_id: int):
160
172
  """Creates an interactable guild resource.
161
173
 
162
174
  Args:
@@ -165,9 +177,11 @@ class Client(ClientLike):
165
177
  Returns:
166
178
  (Guild): the Guild resource
167
179
  """
180
+ from .resources.guild import Guild
181
+
168
182
  return Guild(guild_id, self._http)
169
183
 
170
- def channel_from_id(self, channel_id: int):
184
+ def fetch_channel(self, channel_id: int):
171
185
  """Creates an interactable channel resource.
172
186
 
173
187
  Args:
@@ -176,9 +190,11 @@ class Client(ClientLike):
176
190
  Returns:
177
191
  (Channel): the Channel resource
178
192
  """
193
+ from .resources.channel import Channel
194
+
179
195
  return Channel(channel_id, self._http)
180
196
 
181
- def message_from_id(self, channel_id: int, message_id: int):
197
+ def fetch_message(self, channel_id: int, message_id: int):
182
198
  """Creates an interactable message resource.
183
199
 
184
200
  Args:
@@ -188,9 +204,11 @@ class Client(ClientLike):
188
204
  Returns:
189
205
  (Message): the Message resource
190
206
  """
207
+ from .resources.message import Message
208
+
191
209
  return Message(message_id, channel_id, self._http)
192
210
 
193
- def user_from_id(self, user_id: int):
211
+ def fetch_user(self, user_id: int):
194
212
  """Creates an interactable user resource.
195
213
 
196
214
  Args:
@@ -199,6 +217,8 @@ class Client(ClientLike):
199
217
  Returns:
200
218
  (User): the User resource
201
219
  """
220
+ from .resources.user import User
221
+
202
222
  return User(user_id, self._http)
203
223
 
204
224
  async def clear_guild_commands(self, guild_id: int):
@@ -211,11 +231,11 @@ class Client(ClientLike):
211
231
  self._logger.log_info(f"Guild {guild_id} already queued, skipping clear.")
212
232
  return
213
233
 
214
- await self.command_dispatcher._register_guild_commands({guild_id: []})
234
+ self._guild_commands[guild_id] = []
215
235
 
216
236
  async def _listen(self):
217
237
  """Main event loop for incoming gateway requests."""
218
- while True:
238
+ while self._ws.is_connected():
219
239
  try:
220
240
  message = await self._ws.receive()
221
241
  if not message:
@@ -223,17 +243,17 @@ class Client(ClientLike):
223
243
 
224
244
  op_code = message.get('op')
225
245
 
226
- if op_code == 0:
227
- dispatch_type = message.get('t')
228
- self._logger.log_info(f"DISPATCH -> {dispatch_type}")
229
- event_data = message.get('d')
230
- self._ws.sequence = message.get('s') or self._ws.sequence
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
231
252
 
232
- if dispatch_type == "READY":
233
- self._ws.session_id = event_data.get("session_id")
234
- self._ws.connect_url = event_data.get("resume_gateway_url", self._ws.connect_url)
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)
235
256
 
236
- try:
237
257
  if self.prefix_dispatcher.prefix and dispatch_type == 'MESSAGE_CREATE':
238
258
  await self.prefix_dispatcher.dispatch(event_data)
239
259
 
@@ -241,103 +261,97 @@ class Client(ClientLike):
241
261
  await self.command_dispatcher.dispatch(event_data)
242
262
 
243
263
  await self.dispatcher.dispatch(dispatch_type, event_data)
244
- except DiscordError as e:
245
- if e.fatal:
246
- raise # let run() handle fatal errors
247
- else:
248
- self._logger.log_warn(f"Recoverable DiscordError: {e}")
249
- continue # keep listening
250
-
251
- elif op_code == 7:
252
- raise ConnectionError("Reconnect requested by server.")
253
- elif op_code == 9:
254
- self._ws.session_id = None
255
- self._ws.sequence = None
256
- raise ConnectionError("Invalid session.")
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
+
257
273
  except asyncio.CancelledError:
258
- raise
274
+ break
259
275
  except DiscordError as e:
260
276
  if e.fatal:
261
- raise # propagate fatal errors
277
+ self._logger.log_error(f"Fatal DiscordError: {e}")
278
+ break
262
279
  else:
263
280
  self._logger.log_warn(f"Recoverable DiscordError: {e}")
264
281
  except ConnectionError as e:
265
282
  self._logger.log_warn(f"Connection lost: {e}")
266
- await self._ws.close()
267
- await asyncio.sleep(2)
283
+ raise
268
284
 
269
285
  async def start(self):
270
286
  """Runs the main lifecycle of the bot.
271
287
  Handles connection setup, heartbeat management, event loop, and automatic reconnects.
272
288
  """
273
- while True:
274
- try:
275
- await self._http.start_session()
276
- await self._ws.connect()
277
- await self._ws.start_heartbeat()
289
+ try:
290
+ await self._http.start_session()
291
+ await self._ws.connect()
292
+ await self._ws.start_heartbeat()
278
293
 
294
+ while self._ws.is_connected():
279
295
  if self._ws.session_id and self._ws.sequence:
280
296
  await self._ws.reconnect()
281
297
  else:
282
298
  await self._ws.identify()
283
299
 
284
300
  if not self._is_set_up:
285
- if self._setup_hooks:
286
- for hook in self._setup_hooks:
287
- self._logger.log_info(f"Setting hook {hook.__name__}")
288
- await hook(self)
289
- self._logger.log_high_priority("Hooks set up.")
301
+ await self.startup()
302
+ self._is_set_up = True
290
303
 
291
- # register GUILD commands
292
- await self.command_dispatcher._register_guild_commands(self._guild_commands)
304
+ await self._listen()
305
+
306
+ # If we get here, connection was lost - reconnect
307
+ await self._ws.close()
308
+ await asyncio.sleep(5)
309
+ await self._ws.connect()
310
+ await self._ws.start_heartbeat()
293
311
 
294
- # register GLOBAL commands
295
- await self.command_dispatcher._register_global_commands(self._global_commands)
312
+ except asyncio.CancelledError:
313
+ self._logger.log_high_priority("Connection cancelled via KeyboardInterrupt.")
314
+ except Exception as e:
315
+ self._logger.log_error(f"{type(e).__name__} - {e}")
316
+ finally:
317
+ await self.close()
296
318
 
297
- self._is_set_up = True
319
+ async def startup(self):
320
+ try:
321
+ if self._setup_hooks:
322
+ for hook in self._setup_hooks:
323
+ self._logger.log_info(f"Setting hook {hook.__name__}")
324
+ await hook(self)
325
+ self._logger.log_high_priority("Hooks set up.")
298
326
 
299
- await self._listen()
327
+ # register GUILD commands
328
+ await self.command_dispatcher._register_guild_commands(self._guild_commands)
300
329
 
301
- except ConnectionError as e:
302
- self._logger.log_warn("Connection lost. Attempting reconnect...")
303
- await self._ws.close()
304
- await asyncio.sleep(2)
305
- continue
306
- except asyncio.CancelledError:
307
- self._logger.log_info("Cancelling connection...")
308
- break
309
- except DiscordError as e:
310
- self._logger.log_error(f"Fatal DiscordError: {e}")
311
- break
312
- except Exception as e:
313
- self._logger.log_error(f"Unspecified Error Type {type(e).__name__} - {e}")
314
- break
315
- finally:
316
- # Run hooks (with safe catching)
317
- for hook in self._shutdown_hooks:
318
- try:
319
- self._logger.log_info(f"Executing shutdown hook {hook.__name__}")
320
- await hook(self)
321
- except Exception as e:
322
- self._logger.log_error(f"{type(e).__name__}: {e}")
323
-
324
- # Always close resources
325
- try:
326
- await self._ws.close()
327
- except Exception as e:
328
- self._logger.log_warn(f"WebSocket close failed: {e}")
329
-
330
- try:
331
- await self._http.close_session()
332
- except Exception as e:
333
- self._logger.log_warn(f"HTTP session close failed: {e}")
330
+ # register GLOBAL commands
331
+ await self.command_dispatcher._register_global_commands(self._global_commands)
334
332
 
333
+ self._logger.log_high_priority("Commands set up.")
334
+ except Exception:
335
+ raise
335
336
 
337
+ async def close(self):
338
+ """Gracefully close HTTP and websocket connections."""
339
+ # Close HTTP first since it's more important
340
+ self._logger.log_debug("Closing HTTP session...")
341
+ await self._http.close_session()
342
+
343
+ # Then try websocket with short timeout
344
+ try:
345
+ self._logger.log_debug("Closing websocket connection...")
346
+ await asyncio.wait_for(self._ws.close(), timeout=1.0)
347
+ except:
348
+ pass # Don't care if websocket won't close
349
+
336
350
  def run(self):
337
351
  """Starts the bot.
338
352
  Handles starting the session, WS, and heartbeat, reconnection logic,
339
353
  setting up emojis and hooks, and then listens for gateway events.
340
- """
354
+ """
341
355
  try:
342
356
  asyncio.run(self.start())
343
357
  except KeyboardInterrupt: