scurrypy 0.3.4.3__tar.gz → 0.4.1__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.

Files changed (64) hide show
  1. scurrypy-0.4.1/LICENSE +16 -0
  2. scurrypy-0.4.1/PKG-INFO +130 -0
  3. scurrypy-0.4.1/README.md +120 -0
  4. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/client.py +12 -7
  5. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/dispatch/command_dispatcher.py +3 -2
  6. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/dispatch/event_dispatcher.py +1 -1
  7. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/dispatch/prefix_dispatcher.py +5 -0
  8. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/interaction_events.py +7 -5
  9. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/gateway.py +2 -5
  10. scurrypy-0.4.1/discord/http.py +213 -0
  11. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/logger.py +6 -0
  12. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/action_row.py +7 -56
  13. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/components_v2.py +88 -5
  14. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/message.py +15 -1
  15. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/channel.py +1 -1
  16. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/guild.py +4 -3
  17. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/interaction.py +19 -3
  18. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/message.py +3 -3
  19. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/pyproject.toml +2 -2
  20. scurrypy-0.4.1/scurrypy.egg-info/PKG-INFO +130 -0
  21. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/scurrypy.egg-info/SOURCES.txt +0 -1
  22. scurrypy-0.3.4.3/LICENSE +0 -5
  23. scurrypy-0.3.4.3/PKG-INFO +0 -92
  24. scurrypy-0.3.4.3/README.md +0 -82
  25. scurrypy-0.3.4.3/discord/http.py +0 -280
  26. scurrypy-0.3.4.3/discord/parts/attachment.py +0 -18
  27. scurrypy-0.3.4.3/scurrypy.egg-info/PKG-INFO +0 -92
  28. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/__init__.py +0 -0
  29. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/client_like.py +0 -0
  30. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/config.py +0 -0
  31. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/dispatch/__init__.py +0 -0
  32. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/error.py +0 -0
  33. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/__init__.py +0 -0
  34. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/channel_events.py +0 -0
  35. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/guild_events.py +0 -0
  36. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/hello_event.py +0 -0
  37. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/message_events.py +0 -0
  38. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/reaction_events.py +0 -0
  39. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/ready_event.py +0 -0
  40. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/intents.py +0 -0
  41. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/model.py +0 -0
  42. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/__init__.py +0 -0
  43. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/application.py +0 -0
  44. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/emoji.py +0 -0
  45. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/guild.py +0 -0
  46. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/integration.py +0 -0
  47. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/interaction.py +0 -0
  48. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/member.py +0 -0
  49. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/role.py +0 -0
  50. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/user.py +0 -0
  51. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/__init__.py +0 -0
  52. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/channel.py +0 -0
  53. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/command.py +0 -0
  54. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/component_types.py +0 -0
  55. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/embed.py +0 -0
  56. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/modal.py +0 -0
  57. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/role.py +0 -0
  58. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/__init__.py +0 -0
  59. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/application.py +0 -0
  60. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/bot_emojis.py +0 -0
  61. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/user.py +0 -0
  62. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/scurrypy.egg-info/dependency_links.txt +0 -0
  63. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/scurrypy.egg-info/top_level.txt +0 -0
  64. {scurrypy-0.3.4.3 → scurrypy-0.4.1}/setup.cfg +0 -0
scurrypy-0.4.1/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ Copyright (c) 2025 Furmissile. All rights reserved.
2
+
3
+ Permission is granted to view, use, modify, and distribute copies of this software
4
+ and its source code, provided that:
5
+
6
+ 1. Attribution to the original author, Furmissile, is preserved in all copies and
7
+ derivative works.
8
+ 2. The name "ScurryPy" and associated branding may not be used to promote derived
9
+ projects without explicit permission.
10
+ 3. This license and copyright notice must be included in all copies or substantial
11
+ portions of the software.
12
+ 4. This software is provided "as is", without warranty of any kind, express or
13
+ implied. The author assumes no liability for any damages arising from its use.
14
+
15
+ 5. This software may not be used for commercial purposes without written consent
16
+ from the author.
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: scurrypy
3
+ Version: 0.4.1
4
+ Summary: Dataclass-driven Discord API Wrapper in Python
5
+ Author: Furmissile
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # __Welcome to ScurryPy__
12
+
13
+ [![PyPI version](https://badge.fury.io/py/scurrypy.svg)](https://badge.fury.io/py/scurrypy)
14
+
15
+ > **Official Repository**
16
+ > This is the original and official repository of **ScurryPy**, maintained by [Furmissile](https://github.com/Furmissile).
17
+ > Forks and community extensions are welcome under the project’s license and attribution guidelines.
18
+
19
+ A dataclass-driven Discord API wrapper in Python!
20
+
21
+ While this wrapper is mainly used for various squirrel-related shenanigans, it can also be used for more generic bot purposes.
22
+
23
+ ---
24
+
25
+ ## Features
26
+ * Command and event handling
27
+ * Declarative style using decorators
28
+ * Supports both legacy and new features
29
+ * Respects Discord’s rate limits
30
+
31
+ ---
32
+
33
+ ## Some Things to Consider...
34
+ * This is an early version — feedback, ideas, and contributions are very welcome! That said, there may be bumps along the way, so expect occasional bugs and quirks.
35
+ * Certain features are not yet supported, while others are intentionally omitted. See the [docs](https://furmissile.github.io/scurrypy) for full details.
36
+
37
+ ---
38
+
39
+ ## Getting Started
40
+ *Note: This section also appears in the documentation, but here are complete examples ready to use with your bot credentials.*
41
+
42
+ ### Installation
43
+ To install the ScurryPy package, run:
44
+ ```bash
45
+ pip install scurrypy
46
+ ```
47
+
48
+ ## Minimal Slash Command
49
+ The following demonstrates building and responding to a slash command.
50
+
51
+ *Note: Adjust `dotenv_path` if your `.env` file is not in the same directory as this script.*
52
+
53
+ ```py
54
+ import discord, os
55
+ from dotenv import load_dotenv
56
+
57
+ load_dotenv(dotenv_path='./path/to/env')
58
+
59
+ client = discord.Client(
60
+ token=os.getenv("DISCORD_TOKEN"),
61
+ application_id=APPLICATION_ID # replace with your bot's user ID
62
+ )
63
+
64
+ @client.command(
65
+ command=discord.SlashCommand(
66
+ name='example',
67
+ description='Demonstrate the minimal slash command!'
68
+ ),
69
+ guild_ids=GUILD_ID # must be a guild ID your bot is in
70
+ )
71
+ async def example(bot: discord.Client, event: discord.InteractionEvent):
72
+ await event.interaction.respond(f'Hello, {event.interaction.member.user.username}!')
73
+
74
+ client.run()
75
+ ```
76
+
77
+ ## Minimal Prefix Command (Legacy)
78
+ The following demonstrates building and responding to a message prefix command.
79
+ ```py
80
+ import discord, os
81
+ from dotenv import load_dotenv
82
+
83
+ load_dotenv(dotenv_path='./path/to/env')
84
+
85
+ client = discord.Client(
86
+ token=os.getenv("DISCORD_TOKEN"),
87
+ application_id=APPLICATION_ID, # replace with your bot's user ID
88
+ intents=discord.set_intents(message_content=True),
89
+ prefix='!' # your custom prefix
90
+ )
91
+
92
+ @client.prefix_command
93
+ async def ping(bot: discord.Client, event: discord.MessageCreateEvent):
94
+ # The function name is the name of the command
95
+ await event.message.send("Pong!")
96
+
97
+ client.run()
98
+ ```
99
+
100
+ ## Contribution and Fork Policy
101
+ ScurryPy follows a simple philosophy: **clarity, simplicity, and direct interaction with the Discord API**.
102
+ It favors explicit, dataclass-driven design over heavy abstraction — and contributions should stay true to that style.
103
+
104
+ This is a community-supported project guided by the design and principles of **Furmissile**.
105
+ You are welcome to explore, modify, and extend the codebase under the terms of its license — but please follow these guidelines to ensure proper attribution and clarity.
106
+
107
+ ### You May
108
+ * Fork this repository for personal or collaborative development.
109
+ * Submit pull requests for bug fixes or new features that align with ScurryPy’s goals.
110
+ * Reuse parts of the code in your own projects, provided attribution is preserved.
111
+
112
+ ### You May NOT
113
+ * Remove or alter existing copyright notices or attributions.
114
+ * Present a fork as the official ScurryPy project.
115
+ * Use the name “ScurryPy” or its documentation to promote a fork without permission.
116
+
117
+ If you plan to make substantial changes or release your own variant:
118
+ * Rename the fork to avoid confusion (e.g., `scurrypy-plus` or `scurrypy-extended`).
119
+ * Add a note in your README acknowledging the original project:
120
+ > "This project is a fork of [ScurryPy](https://github.com/Furmissile/scurrypy)
121
+ by Furmissile."
122
+
123
+ ## License
124
+ This project is licensed under the Furmissile License, which allows viewing, modification, and redistribution with proper attribution.
125
+
126
+ See the [License](./LICENSE) for details.
127
+
128
+ ## Like What You See?
129
+ Check out the full [documentation](https://furmissile.github.io/scurrypy)
130
+ for more examples, guides, and API reference!
@@ -0,0 +1,120 @@
1
+ # __Welcome to ScurryPy__
2
+
3
+ [![PyPI version](https://badge.fury.io/py/scurrypy.svg)](https://badge.fury.io/py/scurrypy)
4
+
5
+ > **Official Repository**
6
+ > This is the original and official repository of **ScurryPy**, maintained by [Furmissile](https://github.com/Furmissile).
7
+ > Forks and community extensions are welcome under the project’s license and attribution guidelines.
8
+
9
+ A dataclass-driven Discord API wrapper in Python!
10
+
11
+ While this wrapper is mainly used for various squirrel-related shenanigans, it can also be used for more generic bot purposes.
12
+
13
+ ---
14
+
15
+ ## Features
16
+ * Command and event handling
17
+ * Declarative style using decorators
18
+ * Supports both legacy and new features
19
+ * Respects Discord’s rate limits
20
+
21
+ ---
22
+
23
+ ## Some Things to Consider...
24
+ * This is an early version — feedback, ideas, and contributions are very welcome! That said, there may be bumps along the way, so expect occasional bugs and quirks.
25
+ * Certain features are not yet supported, while others are intentionally omitted. See the [docs](https://furmissile.github.io/scurrypy) for full details.
26
+
27
+ ---
28
+
29
+ ## Getting Started
30
+ *Note: This section also appears in the documentation, but here are complete examples ready to use with your bot credentials.*
31
+
32
+ ### Installation
33
+ To install the ScurryPy package, run:
34
+ ```bash
35
+ pip install scurrypy
36
+ ```
37
+
38
+ ## Minimal Slash Command
39
+ The following demonstrates building and responding to a slash command.
40
+
41
+ *Note: Adjust `dotenv_path` if your `.env` file is not in the same directory as this script.*
42
+
43
+ ```py
44
+ import discord, os
45
+ from dotenv import load_dotenv
46
+
47
+ load_dotenv(dotenv_path='./path/to/env')
48
+
49
+ client = discord.Client(
50
+ token=os.getenv("DISCORD_TOKEN"),
51
+ application_id=APPLICATION_ID # replace with your bot's user ID
52
+ )
53
+
54
+ @client.command(
55
+ command=discord.SlashCommand(
56
+ name='example',
57
+ description='Demonstrate the minimal slash command!'
58
+ ),
59
+ guild_ids=GUILD_ID # must be a guild ID your bot is in
60
+ )
61
+ async def example(bot: discord.Client, event: discord.InteractionEvent):
62
+ await event.interaction.respond(f'Hello, {event.interaction.member.user.username}!')
63
+
64
+ client.run()
65
+ ```
66
+
67
+ ## Minimal Prefix Command (Legacy)
68
+ The following demonstrates building and responding to a message prefix command.
69
+ ```py
70
+ import discord, os
71
+ from dotenv import load_dotenv
72
+
73
+ load_dotenv(dotenv_path='./path/to/env')
74
+
75
+ client = discord.Client(
76
+ token=os.getenv("DISCORD_TOKEN"),
77
+ application_id=APPLICATION_ID, # replace with your bot's user ID
78
+ intents=discord.set_intents(message_content=True),
79
+ prefix='!' # your custom prefix
80
+ )
81
+
82
+ @client.prefix_command
83
+ async def ping(bot: discord.Client, event: discord.MessageCreateEvent):
84
+ # The function name is the name of the command
85
+ await event.message.send("Pong!")
86
+
87
+ client.run()
88
+ ```
89
+
90
+ ## Contribution and Fork Policy
91
+ ScurryPy follows a simple philosophy: **clarity, simplicity, and direct interaction with the Discord API**.
92
+ It favors explicit, dataclass-driven design over heavy abstraction — and contributions should stay true to that style.
93
+
94
+ This is a community-supported project guided by the design and principles of **Furmissile**.
95
+ You are welcome to explore, modify, and extend the codebase under the terms of its license — but please follow these guidelines to ensure proper attribution and clarity.
96
+
97
+ ### You May
98
+ * Fork this repository for personal or collaborative development.
99
+ * Submit pull requests for bug fixes or new features that align with ScurryPy’s goals.
100
+ * Reuse parts of the code in your own projects, provided attribution is preserved.
101
+
102
+ ### You May NOT
103
+ * Remove or alter existing copyright notices or attributions.
104
+ * Present a fork as the official ScurryPy project.
105
+ * Use the name “ScurryPy” or its documentation to promote a fork without permission.
106
+
107
+ If you plan to make substantial changes or release your own variant:
108
+ * Rename the fork to avoid confusion (e.g., `scurrypy-plus` or `scurrypy-extended`).
109
+ * Add a note in your README acknowledging the original project:
110
+ > "This project is a fork of [ScurryPy](https://github.com/Furmissile/scurrypy)
111
+ by Furmissile."
112
+
113
+ ## License
114
+ This project is licensed under the Furmissile License, which allows viewing, modification, and redistribution with proper attribution.
115
+
116
+ See the [License](./LICENSE) for details.
117
+
118
+ ## Like What You See?
119
+ Check out the full [documentation](https://furmissile.github.io/scurrypy)
120
+ for more examples, guides, and API reference!
@@ -228,9 +228,9 @@ class Client(ClientLike):
228
228
  guild_id (int): id of the target guild
229
229
  """
230
230
  if self._guild_commands.get(guild_id):
231
- self._logger.log_info(f"Guild {guild_id} already queued, skipping clear.")
231
+ self._logger.log_warn(f"Guild {guild_id} already queued, skipping clear.")
232
232
  return
233
-
233
+
234
234
  self._guild_commands[guild_id] = []
235
235
 
236
236
  async def _listen(self):
@@ -268,7 +268,7 @@ class Client(ClientLike):
268
268
  self._ws.sequence = None
269
269
  raise ConnectionError("Invalid session.")
270
270
  case 11:
271
- self._logger.log_debug("Heartbeat ACK received")
271
+ self._logger.log_info("Heartbeat ACK received")
272
272
 
273
273
  except asyncio.CancelledError:
274
274
  break
@@ -281,13 +281,17 @@ class Client(ClientLike):
281
281
  except ConnectionError as e:
282
282
  self._logger.log_warn(f"Connection lost: {e}")
283
283
  raise
284
+ except Exception as e:
285
+ self._logger.log_error(f"{type(e).__name__} - {e}")
286
+ self._logger.log_traceback()
287
+ continue
284
288
 
285
- async def start(self):
289
+ async def _start(self):
286
290
  """Runs the main lifecycle of the bot.
287
291
  Handles connection setup, heartbeat management, event loop, and automatic reconnects.
288
292
  """
289
293
  try:
290
- await self._http.start_session()
294
+ await self._http.start()
291
295
  await self._ws.connect()
292
296
  await self._ws.start_heartbeat()
293
297
 
@@ -347,7 +351,7 @@ class Client(ClientLike):
347
351
 
348
352
  # Close HTTP before gateway since it's more important
349
353
  self._logger.log_debug("Closing HTTP session...")
350
- await self._http.close_session()
354
+ await self._http.close()
351
355
 
352
356
  # Then try websocket with short timeout
353
357
  try:
@@ -362,11 +366,12 @@ class Client(ClientLike):
362
366
  setting up emojis and hooks, and then listens for gateway events.
363
367
  """
364
368
  try:
365
- asyncio.run(self.start())
369
+ asyncio.run(self._start())
366
370
  except KeyboardInterrupt:
367
371
  self._logger.log_debug("Shutdown requested via KeyboardInterrupt.")
368
372
  except Exception as e:
369
373
  self._logger.log_error(f"{type(e).__name__} {e}")
374
+ self._logger.log_traceback()
370
375
  finally:
371
376
  self._logger.log_high_priority("Bot shutting down.")
372
377
  self._logger.close()
@@ -64,7 +64,7 @@ class CommandDispatcher:
64
64
  await self._http.request(
65
65
  'PUT',
66
66
  f"applications/{self.application_id}/guilds/{guild_id}/commands",
67
- [command._to_dict() for command in cmds]
67
+ data=[command._to_dict() for command in cmds]
68
68
  )
69
69
 
70
70
  async def _register_global_commands(self, commands: list):
@@ -76,7 +76,7 @@ class CommandDispatcher:
76
76
 
77
77
  global_commands = [command._to_dict() for command in commands]
78
78
 
79
- await self._http.request('PUT', f"applications/{self.application_id}/commands", global_commands)
79
+ await self._http.request('PUT', f"applications/{self.application_id}/commands", data=global_commands)
80
80
 
81
81
  def command(self, name: str, handler):
82
82
  """Decorator to register slash commands.
@@ -161,3 +161,4 @@ class CommandDispatcher:
161
161
  self._logger.log_info(f"Interaction Event '{name}' Acknowledged.")
162
162
  except Exception as e:
163
163
  self._logger.log_error(f"Error in interaction '{name}': {e}")
164
+ self._logger.log_traceback()
@@ -43,7 +43,7 @@ class EventDispatcher:
43
43
  """HTTP session for requests."""
44
44
 
45
45
  self._logger = client._logger
46
- """HTTP session for requests"""
46
+ """Logger instance to log events."""
47
47
 
48
48
  self.config = client.config
49
49
  """User-defined bot config for persistent data."""
@@ -57,3 +57,8 @@ class PrefixDispatcher:
57
57
  self._logger.log_info(f"Prefix Event '{command}' Acknowledged.")
58
58
  except Exception as e:
59
59
  self._logger.log_error(f"Error in prefix command '{command}': {e}")
60
+
61
+ if self._logger.dev_mode:
62
+ import traceback
63
+ traceback.print_exc()
64
+ print("-----------------------------------\n")
@@ -126,11 +126,13 @@ class ModalData(DataModel):
126
126
  if custom_id != component.component.custom_id:
127
127
  continue
128
128
 
129
- match component.component.type:
130
- case 3: # string select -> values
131
- return component.component.values
132
- case 4: # text input -> value
133
- return component.component.value
129
+ t = component.component.type
130
+
131
+ if t in [3,5,6,7,8]: # select menus (w. possibly many option selects!)
132
+ return component.component.values
133
+
134
+ # text input
135
+ return component.component.value
134
136
 
135
137
  raise ValueError(f"Component custom id '{custom_id}' not found.")
136
138
 
@@ -67,7 +67,6 @@ class GatewayClient:
67
67
 
68
68
  if message:
69
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
70
  self._logger.log_info(f"Received: {DISCORD_OP_CODES.get(data.get('op'))}")
72
71
  return data
73
72
 
@@ -79,7 +78,6 @@ class GatewayClient:
79
78
  Args:
80
79
  message (dict): the message to send
81
80
  """
82
- self._logger.log_debug(f"Sending payload: {message}")
83
81
  await self.ws.send(json.dumps(message))
84
82
 
85
83
  async def send_heartbeat_loop(self):
@@ -90,8 +88,7 @@ class GatewayClient:
90
88
  await asyncio.sleep(self.heartbeat_interval / 1000)
91
89
  hb_data = {"op": 1, "d": self.sequence}
92
90
  await self.send(hb_data)
93
- self._logger.log_debug(f"Sending: {hb_data}")
94
- self._logger.log_info("Heartbeat sent.")
91
+ self._logger.log_debug(f"Sent HEARTBEAT: {hb_data}")
95
92
 
96
93
  async def identify(self):
97
94
  """Sends the IDENIFY payload (token, intents, connection properties).
@@ -111,7 +108,7 @@ class GatewayClient:
111
108
  }
112
109
  await self.send(i)
113
110
  log_i = self._logger.redact(i)
114
- self._logger.log_debug(f"Sending: {log_i}")
111
+ self._logger.log_debug(f"Sent IDENTIFY: {log_i}")
115
112
  self._logger.log_high_priority("Identify sent.")
116
113
 
117
114
  async def start_heartbeat(self):
@@ -0,0 +1,213 @@
1
+ import aiohttp
2
+ import aiofiles
3
+ import asyncio
4
+ import json
5
+
6
+ from typing import Any, Optional
7
+
8
+ from .logger import Logger
9
+
10
+ class HTTPException(Exception):
11
+ """Represents an HTTP error response from Discord."""
12
+ def __init__(self, response: aiohttp.ClientResponse, message: str):
13
+ self.response = response
14
+ self.status = response.status
15
+ self.text = message
16
+ super().__init__(f"{response.status}: {message}")
17
+
18
+ class HTTPClient:
19
+ BASE = "https://discord.com/api/v10"
20
+ MAX_RETRIES = 3
21
+
22
+ def __init__(self, token: str, logger: Logger):
23
+ self.token = token
24
+ self.session: Optional[aiohttp.ClientSession] = None
25
+ self.logger = logger
26
+ self.global_reset = 0.0
27
+ self.global_lock = asyncio.Lock()
28
+ self.endpoint_to_bucket: dict[str, str] = {}
29
+ self.queues: dict[str, asyncio.Queue] = {}
30
+ self.workers: dict[str, asyncio.Task] = {}
31
+
32
+ async def start(self):
33
+ """Start the HTTP session."""
34
+ if not self.session:
35
+ self.session = aiohttp.ClientSession(
36
+ headers={"Authorization": f"Bot {self.token}"}
37
+ )
38
+
39
+ async def close(self):
40
+ """Close the HTTP session."""
41
+ for task in self.workers.values():
42
+ task.cancel()
43
+ if self.session and not self.session.closed:
44
+ await self.session.close()
45
+
46
+ async def request(
47
+ self,
48
+ method: str,
49
+ endpoint: str,
50
+ *,
51
+ data: Any | None = None,
52
+ params: dict | None = None,
53
+ files: Any | None = None,
54
+ ):
55
+ """Enqueues request WRT rate-limit buckets.
56
+
57
+ Args:
58
+ method (str): HTTP method (e.g., POST, GET, DELETE, PATCH, etc.)
59
+ endpoint (str): Discord endpoint (e.g., /channels/123/messages)
60
+ data (dict, optional): relevant data
61
+ params (dict, optional): relevant query params
62
+ files (list[str], optional): relevant files
63
+
64
+ Returns:
65
+ (Future): future with response
66
+ """
67
+ if not self.session:
68
+ await self.start()
69
+
70
+ bucket = self.endpoint_to_bucket.get(endpoint, endpoint)
71
+ queue = self.queues.setdefault(bucket, asyncio.Queue())
72
+ future = asyncio.get_event_loop().create_future()
73
+
74
+ await queue.put((method, endpoint, data, params, files, future))
75
+ if bucket not in self.workers:
76
+ self.workers[bucket] = asyncio.create_task(self._worker(bucket))
77
+
78
+ return await future
79
+
80
+ async def _worker(self, bucket: str):
81
+ """Processes request from specific rate-limit bucket."""
82
+
83
+ q = self.queues[bucket]
84
+ while self.session:
85
+ method, endpoint, data, params, files, future = await q.get()
86
+ try:
87
+ result = await self._send(method, endpoint, data, params, files)
88
+ if not future.done():
89
+ future.set_result(result)
90
+ except Exception as e:
91
+ if not future.done():
92
+ future.set_exception(e)
93
+ finally:
94
+ q.task_done()
95
+
96
+ async def _send(
97
+ self,
98
+ method: str,
99
+ endpoint: str,
100
+ data: Any | None,
101
+ params: dict | None,
102
+ files: Any | None,
103
+ ):
104
+ """Core HTTP request executor.
105
+
106
+ Sends a request to Discord, handling JSON payloads, files, query parameters,
107
+ rate limits, and retries.
108
+
109
+ Args:
110
+ method (str): HTTP method (e.g., 'POST', 'GET', 'DELETE', 'PATCH').
111
+ endpoint (str): Discord API endpoint (e.g., '/channels/123/messages').
112
+ data (dict | None, optional): JSON payload to include in the request body.
113
+ params (dict | None, optional): Query parameters to append to the URL.
114
+ files (list[str] | None, optional): Files to send with the request.
115
+
116
+ Raises:
117
+ (HTTPException): If the request fails after the maximum number of retries
118
+ or receives an error response.
119
+
120
+ Returns:
121
+ (dict | str | None): Parsed JSON response if available, raw text if the
122
+ response is not JSON, or None for HTTP 204 responses.
123
+ """
124
+
125
+ url = f"{self.BASE.rstrip('/')}/{endpoint.lstrip('/')}"
126
+
127
+ def sanitize_query_params(params: dict | None) -> dict | None:
128
+ if not params:
129
+ return None
130
+ return {k: ('true' if v is True else 'false' if v is False else v)
131
+ for k, v in params.items() if v is not None}
132
+
133
+ for attempt in range(self.MAX_RETRIES):
134
+ await self._check_global_limit()
135
+
136
+ kwargs = {}
137
+
138
+ if files and any(files):
139
+ payload, headers = await self._make_payload(data, files)
140
+ kwargs = {"data": payload, "headers": headers}
141
+ else:
142
+ kwargs = {"json": data}
143
+
144
+ try:
145
+ async with self.session.request(
146
+ method, url, params=sanitize_query_params(params), timeout=15, **kwargs
147
+ ) as resp:
148
+ if resp.status == 429:
149
+ data = await resp.json()
150
+ retry = float(data.get("retry_after", 1))
151
+ if data.get("global"):
152
+ self.global_reset = asyncio.get_event_loop().time() + retry
153
+ self.logger.log_warn(
154
+ f"Rate limited {retry}s ({'global' if data.get('global') else 'bucket'})"
155
+ )
156
+ await asyncio.sleep(retry + 0.5)
157
+ continue
158
+
159
+ if 200 <= resp.status < 300:
160
+ if resp.status == 204:
161
+ return None
162
+ try:
163
+ return await resp.json()
164
+ except aiohttp.ContentTypeError:
165
+ return await resp.text()
166
+
167
+ text = await resp.text()
168
+ raise HTTPException(resp, text)
169
+
170
+ except asyncio.TimeoutError:
171
+ self.logger.log_warn(f"Timeout on {method} {endpoint}, retrying...")
172
+ continue
173
+
174
+ raise HTTPException(resp, f"Failed after {self.MAX_RETRIES} retries")
175
+
176
+ async def _check_global_limit(self):
177
+ """Waits if the global rate-limit is in effect."""
178
+
179
+ now = asyncio.get_event_loop().time()
180
+ if now < self.global_reset:
181
+ delay = self.global_reset - now
182
+ self.logger.log_warn(f"Global rate limit active, sleeping {delay:.2f}s")
183
+ await asyncio.sleep(delay)
184
+
185
+ async def _make_payload(self, data: dict, files: list):
186
+ """Return (data, headers) for aiohttp request — supports multipart.
187
+
188
+ Args:
189
+ data (dict): request data
190
+ files (list): relevant files
191
+
192
+ Returns:
193
+ (tuple[aiohttp.FormData, dict]): form data and headers
194
+ """
195
+ headers = {}
196
+ if not files:
197
+ return data, headers
198
+
199
+ form = aiohttp.FormData()
200
+ if data:
201
+ form.add_field("payload_json", json.dumps(data))
202
+
203
+ for idx, file_path in enumerate(files):
204
+ async with aiofiles.open(file_path, 'rb') as f:
205
+ data = await f.read()
206
+ form.add_field(
207
+ f'files[{idx}]',
208
+ data,
209
+ filename=file_path.split('/')[-1],
210
+ content_type='application/octet-stream'
211
+ )
212
+
213
+ return form, headers
@@ -35,6 +35,7 @@ class Logger:
35
35
  """Log file for writing."""
36
36
  except Exception as e:
37
37
  self.log_error(f"Error {type(e)}: {e}")
38
+ self.log_traceback()
38
39
 
39
40
  self.dev_mode = dev_mode
40
41
  """If debug logs should be printed."""
@@ -52,6 +53,11 @@ class Logger:
52
53
 
53
54
  return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
54
55
 
56
+ def log_traceback(self):
57
+ if self.dev_mode == True:
58
+ import traceback
59
+ self._log("DEBUG", self.DEBUG, traceback.format_exc())
60
+
55
61
  def _log(self, level: str, color: str, message: str):
56
62
  """Internal helper that writes formatted log to both file and console.
57
63