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.
- scurrypy-0.4.1/LICENSE +16 -0
- scurrypy-0.4.1/PKG-INFO +130 -0
- scurrypy-0.4.1/README.md +120 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/client.py +12 -7
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/dispatch/command_dispatcher.py +3 -2
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/dispatch/event_dispatcher.py +1 -1
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/dispatch/prefix_dispatcher.py +5 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/interaction_events.py +7 -5
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/gateway.py +2 -5
- scurrypy-0.4.1/discord/http.py +213 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/logger.py +6 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/action_row.py +7 -56
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/components_v2.py +88 -5
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/message.py +15 -1
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/channel.py +1 -1
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/guild.py +4 -3
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/interaction.py +19 -3
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/message.py +3 -3
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/pyproject.toml +2 -2
- scurrypy-0.4.1/scurrypy.egg-info/PKG-INFO +130 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/scurrypy.egg-info/SOURCES.txt +0 -1
- scurrypy-0.3.4.3/LICENSE +0 -5
- scurrypy-0.3.4.3/PKG-INFO +0 -92
- scurrypy-0.3.4.3/README.md +0 -82
- scurrypy-0.3.4.3/discord/http.py +0 -280
- scurrypy-0.3.4.3/discord/parts/attachment.py +0 -18
- scurrypy-0.3.4.3/scurrypy.egg-info/PKG-INFO +0 -92
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/__init__.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/client_like.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/config.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/dispatch/__init__.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/error.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/__init__.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/channel_events.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/guild_events.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/hello_event.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/message_events.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/reaction_events.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/events/ready_event.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/intents.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/model.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/__init__.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/application.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/emoji.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/guild.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/integration.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/interaction.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/member.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/role.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/models/user.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/__init__.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/channel.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/command.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/component_types.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/embed.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/modal.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/parts/role.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/__init__.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/application.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/bot_emojis.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/discord/resources/user.py +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/scurrypy.egg-info/dependency_links.txt +0 -0
- {scurrypy-0.3.4.3 → scurrypy-0.4.1}/scurrypy.egg-info/top_level.txt +0 -0
- {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.
|
scurrypy-0.4.1/PKG-INFO
ADDED
|
@@ -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
|
+
[](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!
|
scurrypy-0.4.1/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# __Welcome to ScurryPy__
|
|
2
|
+
|
|
3
|
+
[](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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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()
|
|
@@ -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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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"
|
|
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"
|
|
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
|
|