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.
Files changed (72) hide show
  1. scurrypy/__init__.py +429 -0
  2. scurrypy/client.py +335 -0
  3. {discord → scurrypy}/client_like.py +8 -1
  4. scurrypy/dispatch/command_dispatcher.py +205 -0
  5. {discord → scurrypy}/dispatch/event_dispatcher.py +21 -21
  6. {discord → scurrypy}/dispatch/prefix_dispatcher.py +31 -12
  7. {discord → scurrypy}/error.py +6 -18
  8. {discord → scurrypy}/events/channel_events.py +2 -1
  9. scurrypy/events/gateway_events.py +31 -0
  10. {discord → scurrypy}/events/guild_events.py +2 -1
  11. {discord → scurrypy}/events/interaction_events.py +28 -13
  12. {discord → scurrypy}/events/message_events.py +8 -5
  13. {discord → scurrypy}/events/reaction_events.py +1 -2
  14. {discord → scurrypy}/events/ready_event.py +1 -3
  15. scurrypy/gateway.py +183 -0
  16. scurrypy/http.py +310 -0
  17. {discord → scurrypy}/intents.py +5 -7
  18. {discord → scurrypy}/logger.py +14 -61
  19. scurrypy/model.py +71 -0
  20. scurrypy/models.py +258 -0
  21. scurrypy/parts/channel.py +42 -0
  22. scurrypy/parts/command.py +90 -0
  23. scurrypy/parts/components.py +224 -0
  24. scurrypy/parts/components_v2.py +144 -0
  25. scurrypy/parts/embed.py +83 -0
  26. scurrypy/parts/message.py +134 -0
  27. scurrypy/parts/modal.py +16 -0
  28. {discord → scurrypy}/parts/role.py +2 -14
  29. {discord → scurrypy}/resources/application.py +1 -2
  30. {discord → scurrypy}/resources/bot_emojis.py +1 -1
  31. {discord → scurrypy}/resources/channel.py +9 -8
  32. {discord → scurrypy}/resources/guild.py +14 -16
  33. {discord → scurrypy}/resources/interaction.py +50 -43
  34. {discord → scurrypy}/resources/message.py +15 -16
  35. {discord → scurrypy}/resources/user.py +3 -4
  36. scurrypy-0.6.6.dist-info/METADATA +108 -0
  37. scurrypy-0.6.6.dist-info/RECORD +47 -0
  38. {scurrypy-0.4.dist-info → scurrypy-0.6.6.dist-info}/licenses/LICENSE +1 -1
  39. scurrypy-0.6.6.dist-info/top_level.txt +1 -0
  40. discord/__init__.py +0 -223
  41. discord/client.py +0 -375
  42. discord/dispatch/command_dispatcher.py +0 -163
  43. discord/gateway.py +0 -155
  44. discord/http.py +0 -280
  45. discord/model.py +0 -90
  46. discord/models/__init__.py +0 -1
  47. discord/models/application.py +0 -37
  48. discord/models/emoji.py +0 -34
  49. discord/models/guild.py +0 -35
  50. discord/models/integration.py +0 -23
  51. discord/models/interaction.py +0 -26
  52. discord/models/member.py +0 -27
  53. discord/models/role.py +0 -53
  54. discord/models/user.py +0 -15
  55. discord/parts/action_row.py +0 -208
  56. discord/parts/channel.py +0 -20
  57. discord/parts/command.py +0 -102
  58. discord/parts/components_v2.py +0 -353
  59. discord/parts/embed.py +0 -154
  60. discord/parts/message.py +0 -194
  61. discord/parts/modal.py +0 -21
  62. scurrypy-0.4.dist-info/METADATA +0 -130
  63. scurrypy-0.4.dist-info/RECORD +0 -54
  64. scurrypy-0.4.dist-info/top_level.txt +0 -1
  65. {discord → scurrypy}/config.py +0 -0
  66. {discord → scurrypy}/dispatch/__init__.py +0 -0
  67. {discord → scurrypy}/events/__init__.py +0 -0
  68. {discord → scurrypy}/events/hello_event.py +0 -0
  69. {discord → scurrypy}/parts/__init__.py +0 -0
  70. {discord → scurrypy}/parts/component_types.py +0 -0
  71. {discord → scurrypy}/resources/__init__.py +0 -0
  72. {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