scurrypy 0.4__py3-none-any.whl → 0.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/client.py +9 -7
- discord/dispatch/command_dispatcher.py +3 -2
- discord/dispatch/event_dispatcher.py +1 -1
- discord/dispatch/prefix_dispatcher.py +5 -0
- discord/gateway.py +2 -5
- discord/http.py +167 -234
- discord/logger.py +6 -0
- discord/resources/channel.py +1 -1
- discord/resources/guild.py +4 -3
- discord/resources/interaction.py +3 -3
- discord/resources/message.py +3 -3
- {scurrypy-0.4.dist-info → scurrypy-0.4.1.dist-info}/METADATA +1 -1
- {scurrypy-0.4.dist-info → scurrypy-0.4.1.dist-info}/RECORD +16 -16
- {scurrypy-0.4.dist-info → scurrypy-0.4.1.dist-info}/WHEEL +0 -0
- {scurrypy-0.4.dist-info → scurrypy-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {scurrypy-0.4.dist-info → scurrypy-0.4.1.dist-info}/top_level.txt +0 -0
discord/client.py
CHANGED
|
@@ -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
|
|
@@ -283,14 +283,15 @@ class Client(ClientLike):
|
|
|
283
283
|
raise
|
|
284
284
|
except Exception as e:
|
|
285
285
|
self._logger.log_error(f"{type(e).__name__} - {e}")
|
|
286
|
+
self._logger.log_traceback()
|
|
286
287
|
continue
|
|
287
288
|
|
|
288
|
-
async def
|
|
289
|
+
async def _start(self):
|
|
289
290
|
"""Runs the main lifecycle of the bot.
|
|
290
291
|
Handles connection setup, heartbeat management, event loop, and automatic reconnects.
|
|
291
292
|
"""
|
|
292
293
|
try:
|
|
293
|
-
await self._http.
|
|
294
|
+
await self._http.start()
|
|
294
295
|
await self._ws.connect()
|
|
295
296
|
await self._ws.start_heartbeat()
|
|
296
297
|
|
|
@@ -350,7 +351,7 @@ class Client(ClientLike):
|
|
|
350
351
|
|
|
351
352
|
# Close HTTP before gateway since it's more important
|
|
352
353
|
self._logger.log_debug("Closing HTTP session...")
|
|
353
|
-
await self._http.
|
|
354
|
+
await self._http.close()
|
|
354
355
|
|
|
355
356
|
# Then try websocket with short timeout
|
|
356
357
|
try:
|
|
@@ -365,11 +366,12 @@ class Client(ClientLike):
|
|
|
365
366
|
setting up emojis and hooks, and then listens for gateway events.
|
|
366
367
|
"""
|
|
367
368
|
try:
|
|
368
|
-
asyncio.run(self.
|
|
369
|
+
asyncio.run(self._start())
|
|
369
370
|
except KeyboardInterrupt:
|
|
370
371
|
self._logger.log_debug("Shutdown requested via KeyboardInterrupt.")
|
|
371
372
|
except Exception as e:
|
|
372
373
|
self._logger.log_error(f"{type(e).__name__} {e}")
|
|
374
|
+
self._logger.log_traceback()
|
|
373
375
|
finally:
|
|
374
376
|
self._logger.log_high_priority("Bot shutting down.")
|
|
375
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")
|
discord/gateway.py
CHANGED
|
@@ -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):
|
discord/http.py
CHANGED
|
@@ -1,280 +1,213 @@
|
|
|
1
1
|
import aiohttp
|
|
2
2
|
import aiofiles
|
|
3
3
|
import asyncio
|
|
4
|
-
import time
|
|
5
4
|
import json
|
|
6
|
-
|
|
7
|
-
from
|
|
8
|
-
from urllib.parse import urlencode
|
|
5
|
+
|
|
6
|
+
from typing import Any, Optional
|
|
9
7
|
|
|
10
8
|
from .logger import Logger
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
204: "No Content",
|
|
20
|
-
304: "Not Modified",
|
|
21
|
-
400: "Bad Request",
|
|
22
|
-
401: "Missing Authorization",
|
|
23
|
-
403: "Missing Permission",
|
|
24
|
-
404: "Resource Not Found",
|
|
25
|
-
405: "Invalid Method",
|
|
26
|
-
429: "Rate Limited",
|
|
27
|
-
502: "Gateway Unavailable"
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
@dataclass
|
|
31
|
-
class RequestItem:
|
|
32
|
-
"""Data container representing an HTTP request to Discord's API.
|
|
33
|
-
Used internally by HTTPClient for queuing and processing requests.
|
|
34
|
-
"""
|
|
35
|
-
method: str
|
|
36
|
-
"""HTTP method (e.g., GET, POST, DELETE, PUT, PATCH)"""
|
|
37
|
-
|
|
38
|
-
url: str
|
|
39
|
-
"""Fully qualifying URL for this request."""
|
|
40
|
-
|
|
41
|
-
endpoint: str
|
|
42
|
-
"""Endpoint of the URL for this request."""
|
|
43
|
-
|
|
44
|
-
data: dict | None
|
|
45
|
-
"""Relevant data for this request."""
|
|
46
|
-
|
|
47
|
-
files: list | None
|
|
48
|
-
"""Relevant files for this request."""
|
|
49
|
-
|
|
50
|
-
future: asyncio.Future
|
|
51
|
-
"""Track the result of this request."""
|
|
52
|
-
|
|
53
|
-
class RouteQueue:
|
|
54
|
-
"""Represents a queue of requests for a single rate-limit bucket.
|
|
55
|
-
Manages task worker that processes requests for that bucket.
|
|
56
|
-
"""
|
|
57
|
-
def __init__(self):
|
|
58
|
-
self.queue = asyncio.Queue()
|
|
59
|
-
"""Queue holding RequestItem for this bucket."""
|
|
60
|
-
|
|
61
|
-
self.worker = None
|
|
62
|
-
"""Process for executing request for this bucket."""
|
|
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}")
|
|
63
17
|
|
|
64
18
|
class HTTPClient:
|
|
65
|
-
""
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"""
|
|
19
|
+
BASE = "https://discord.com/api/v10"
|
|
20
|
+
MAX_RETRIES = 3
|
|
21
|
+
|
|
69
22
|
def __init__(self, token: str, logger: Logger):
|
|
70
23
|
self.token = token
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
self.
|
|
74
|
-
"""Logger instance to log events."""
|
|
75
|
-
|
|
76
|
-
self.session: aiohttp.ClientSession = None
|
|
77
|
-
"""Client session instance."""
|
|
78
|
-
|
|
79
|
-
self.global_reset = 0
|
|
80
|
-
"""Global rete limit cooldown if a global rate limit is active."""
|
|
81
|
-
|
|
82
|
-
self.global_lock: asyncio.Lock = None
|
|
83
|
-
"""Lock on queues to avoid race conditions."""
|
|
84
|
-
|
|
85
|
-
self.pending_queue: asyncio.Queue = None
|
|
86
|
-
"""Queue for requests not yet assigned to bucket."""
|
|
87
|
-
|
|
88
|
-
self.pending_worker: asyncio.Task = None
|
|
89
|
-
"""Task processing for the pending queue."""
|
|
90
|
-
|
|
91
|
-
self.endpoint_to_bucket: dict[str, str] = {}
|
|
92
|
-
"""Maps endpoints to rate-limit buckets"""
|
|
93
|
-
|
|
94
|
-
self.bucket_queues: dict[str, RouteQueue] = {}
|
|
95
|
-
"""Maps endpoints to RouteQueue objects."""
|
|
96
|
-
|
|
97
|
-
self._sentinel = object()
|
|
98
|
-
"""Sentinel to terminate session."""
|
|
99
|
-
|
|
100
|
-
self.base_url = "https://discord.com/api/v10"
|
|
101
|
-
"""Base URL for discord's API requests."""
|
|
102
|
-
|
|
103
|
-
async def start_session(self):
|
|
104
|
-
"""Initializes aiohttp session, queues, locks, and starting pending worker."""
|
|
105
|
-
self.session = aiohttp.ClientSession()
|
|
106
|
-
self.pending_queue = asyncio.Queue()
|
|
24
|
+
self.session: Optional[aiohttp.ClientSession] = None
|
|
25
|
+
self.logger = logger
|
|
26
|
+
self.global_reset = 0.0
|
|
107
27
|
self.global_lock = asyncio.Lock()
|
|
108
|
-
self.
|
|
109
|
-
self.
|
|
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()
|
|
110
45
|
|
|
111
|
-
async def request(
|
|
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
|
+
):
|
|
112
55
|
"""Enqueues request WRT rate-limit buckets.
|
|
113
56
|
|
|
114
57
|
Args:
|
|
115
58
|
method (str): HTTP method (e.g., POST, GET, DELETE, PATCH, etc.)
|
|
116
59
|
endpoint (str): Discord endpoint (e.g., /channels/123/messages)
|
|
117
60
|
data (dict, optional): relevant data
|
|
61
|
+
params (dict, optional): relevant query params
|
|
118
62
|
files (list[str], optional): relevant files
|
|
119
63
|
|
|
120
64
|
Returns:
|
|
121
65
|
(Future): future with response
|
|
122
66
|
"""
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
url += f"?{urlencode(params)}"
|
|
67
|
+
if not self.session:
|
|
68
|
+
await self.start()
|
|
126
69
|
|
|
70
|
+
bucket = self.endpoint_to_bucket.get(endpoint, endpoint)
|
|
71
|
+
queue = self.queues.setdefault(bucket, asyncio.Queue())
|
|
127
72
|
future = asyncio.get_event_loop().create_future()
|
|
128
73
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
self.bucket_queues[bucket] = RouteQueue()
|
|
133
|
-
self.bucket_queues[bucket].worker = asyncio.create_task(
|
|
134
|
-
self._route_worker(bucket)
|
|
135
|
-
)
|
|
136
|
-
await self.bucket_queues[bucket].queue.put(RequestItem(method, url, endpoint, data, files, future))
|
|
137
|
-
else:
|
|
138
|
-
await self.pending_queue.put(RequestItem(method, url, endpoint, data, files, future))
|
|
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))
|
|
139
77
|
|
|
140
78
|
return await future
|
|
141
79
|
|
|
142
|
-
async def
|
|
143
|
-
"""Processes
|
|
144
|
-
while True:
|
|
145
|
-
item = await self.pending_queue.get()
|
|
146
|
-
if item is self._sentinel:
|
|
147
|
-
self.pending_queue.task_done()
|
|
148
|
-
break
|
|
149
|
-
await self._process_request(item)
|
|
150
|
-
self.pending_queue.task_done()
|
|
151
|
-
|
|
152
|
-
async def _route_worker(self, bucket: str):
|
|
153
|
-
"""Processes request from specific rate-limit bucket.
|
|
80
|
+
async def _worker(self, bucket: str):
|
|
81
|
+
"""Processes request from specific rate-limit bucket."""
|
|
154
82
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
169
108
|
|
|
170
109
|
Args:
|
|
171
|
-
|
|
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.
|
|
172
115
|
|
|
173
116
|
Raises:
|
|
174
|
-
|
|
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.
|
|
175
123
|
"""
|
|
176
|
-
try:
|
|
177
|
-
await self._check_global_limit()
|
|
178
124
|
|
|
179
|
-
|
|
125
|
+
url = f"{self.BASE.rstrip('/')}/{endpoint.lstrip('/')}"
|
|
180
126
|
|
|
181
|
-
|
|
182
|
-
|
|
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}
|
|
183
132
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
form.add_field('payload_json', json.dumps(item.data))
|
|
133
|
+
for attempt in range(self.MAX_RETRIES):
|
|
134
|
+
await self._check_global_limit()
|
|
187
135
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
async with self.session.request(item.method, item.url, **request_kwargs) as resp:
|
|
208
|
-
self._logger.log_debug(f"{item.method} {item.endpoint}: {resp.status} {DISCORD_HTTP_CODES.get(resp.status, 'Unknown Status')}")
|
|
209
|
-
|
|
210
|
-
# if triggered rate-limit
|
|
211
|
-
if resp.status == 429:
|
|
212
|
-
data = await resp.json()
|
|
213
|
-
retry_after = float(data.get("retry_after", 1))
|
|
214
|
-
is_global = data.get("global")
|
|
215
|
-
if is_global:
|
|
216
|
-
self.global_reset = time.time() + retry_after
|
|
217
|
-
|
|
218
|
-
try:
|
|
219
|
-
self._logger.log_warn( f"You are being rate limited on {item.endpoint}. Retrying in {retry_after}s (global={is_global})")
|
|
220
|
-
await asyncio.sleep(retry_after + 0.5)
|
|
221
|
-
except asyncio.CancelledError:
|
|
222
|
-
# shutdown is happening
|
|
223
|
-
raise
|
|
224
|
-
|
|
225
|
-
# retry the request
|
|
226
|
-
await self._process_request(item)
|
|
227
|
-
return
|
|
228
|
-
|
|
229
|
-
# if response failed (either lethal or recoverable)
|
|
230
|
-
elif resp.status not in [200, 201, 204]:
|
|
231
|
-
raise DiscordError(resp.status, await resp.json())
|
|
232
|
-
|
|
233
|
-
await self._handle_response(item, resp)
|
|
234
|
-
|
|
235
|
-
# Handle rate-limit bucket headers
|
|
236
|
-
bucket = resp.headers.get("X-RateLimit-Bucket")
|
|
237
|
-
if bucket and item.endpoint not in self.endpoint_to_bucket:
|
|
238
|
-
self.endpoint_to_bucket[item.endpoint] = bucket
|
|
239
|
-
if bucket not in self.bucket_queues:
|
|
240
|
-
self.bucket_queues[bucket] = RouteQueue()
|
|
241
|
-
self.bucket_queues[bucket].worker = asyncio.create_task(
|
|
242
|
-
self._route_worker(bucket)
|
|
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'})"
|
|
243
155
|
)
|
|
156
|
+
await asyncio.sleep(retry + 0.5)
|
|
157
|
+
continue
|
|
244
158
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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()
|
|
248
166
|
|
|
249
|
-
|
|
250
|
-
|
|
167
|
+
text = await resp.text()
|
|
168
|
+
raise HTTPException(resp, text)
|
|
251
169
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
item.future.set_result((None, 204))
|
|
258
|
-
else:
|
|
259
|
-
try:
|
|
260
|
-
result = await resp.json()
|
|
261
|
-
except aiohttp.ContentTypeError:
|
|
262
|
-
result = await resp.text()
|
|
263
|
-
item.future.set_result(result)
|
|
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")
|
|
264
175
|
|
|
265
176
|
async def _check_global_limit(self):
|
|
266
177
|
"""Waits if the global rate-limit is in effect."""
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
for
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
discord/logger.py
CHANGED
|
@@ -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
|
|
discord/resources/channel.py
CHANGED
|
@@ -148,7 +148,7 @@ class Channel(DataModel):
|
|
|
148
148
|
if isinstance(message, str):
|
|
149
149
|
message = MessageBuilder(content=message)
|
|
150
150
|
|
|
151
|
-
data = await self._http.request("POST", f"/channels/{self.id}/messages", message._to_dict())
|
|
151
|
+
data = await self._http.request("POST", f"/channels/{self.id}/messages", data=message._to_dict())
|
|
152
152
|
|
|
153
153
|
return Message.from_dict(data, self._http)
|
|
154
154
|
|
discord/resources/guild.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
2
|
from typing import Optional, TypedDict, Unpack
|
|
3
|
+
from urllib.parse import urlencode
|
|
3
4
|
|
|
4
5
|
from ..http import HTTPClient
|
|
5
6
|
from ..model import DataModel
|
|
@@ -136,7 +137,7 @@ class Guild(DataModel):
|
|
|
136
137
|
Returns:
|
|
137
138
|
(Channel): the created channel
|
|
138
139
|
"""
|
|
139
|
-
data = await self._http.request('POST', f'/guilds/{self.id}/channels', channel._to_dict())
|
|
140
|
+
data = await self._http.request('POST', f'/guilds/{self.id}/channels', data=channel._to_dict())
|
|
140
141
|
|
|
141
142
|
return Channel.from_dict(data, self._http)
|
|
142
143
|
|
|
@@ -233,7 +234,7 @@ class Guild(DataModel):
|
|
|
233
234
|
Returns:
|
|
234
235
|
(RoleModel): new role data
|
|
235
236
|
"""
|
|
236
|
-
data = await self._http.request('POST', f'/guilds/{self.id}/roles', role._to_dict())
|
|
237
|
+
data = await self._http.request('POST', f'/guilds/{self.id}/roles', data=role._to_dict())
|
|
237
238
|
|
|
238
239
|
return RoleModel.from_dict(data)
|
|
239
240
|
|
|
@@ -249,7 +250,7 @@ class Guild(DataModel):
|
|
|
249
250
|
Returns:
|
|
250
251
|
(RoleModel): role with changes
|
|
251
252
|
"""
|
|
252
|
-
data = await self._http.request('PATCH', f'/guilds/{self.id}/roles/{role_id}', role._to_dict())
|
|
253
|
+
data = await self._http.request('PATCH', f'/guilds/{self.id}/roles/{role_id}', data=role._to_dict())
|
|
253
254
|
|
|
254
255
|
return RoleModel.from_dict(data)
|
|
255
256
|
|
discord/resources/interaction.py
CHANGED
|
@@ -122,7 +122,7 @@ class Interaction(DataModel):
|
|
|
122
122
|
data = await self._http.request(
|
|
123
123
|
'POST',
|
|
124
124
|
f'/interactions/{self.id}/{self.token}/callback',
|
|
125
|
-
content,
|
|
125
|
+
data=content,
|
|
126
126
|
files=[fp.path for fp in message.attachments],
|
|
127
127
|
params=params)
|
|
128
128
|
|
|
@@ -151,7 +151,7 @@ class Interaction(DataModel):
|
|
|
151
151
|
await self._http.request(
|
|
152
152
|
'POST',
|
|
153
153
|
f'/interactions/{self.id}/{self.token}/callback',
|
|
154
|
-
content,
|
|
154
|
+
data=content,
|
|
155
155
|
files=[fp.path for fp in message.attachments])
|
|
156
156
|
|
|
157
157
|
async def respond_modal(self, modal: ModalBuilder):
|
|
@@ -174,4 +174,4 @@ class Interaction(DataModel):
|
|
|
174
174
|
await self._http.request(
|
|
175
175
|
'POST',
|
|
176
176
|
f'/interactions/{self.id}/{self.token}/callback',
|
|
177
|
-
content)
|
|
177
|
+
data=content)
|
discord/resources/message.py
CHANGED
|
@@ -68,7 +68,7 @@ class Message(DataModel):
|
|
|
68
68
|
data = await self._http.request(
|
|
69
69
|
"POST",
|
|
70
70
|
f"/channels/{self.channel_id}/messages",
|
|
71
|
-
message._to_dict(),
|
|
71
|
+
data=message._to_dict(),
|
|
72
72
|
files=[fp.path for fp in message.attachments] if message.attachments else None
|
|
73
73
|
)
|
|
74
74
|
return Message.from_dict(data, self._http)
|
|
@@ -88,7 +88,7 @@ class Message(DataModel):
|
|
|
88
88
|
data = await self._http.request(
|
|
89
89
|
"PATCH",
|
|
90
90
|
f"/channels/{self.channel_id}/messages/{self.id}",
|
|
91
|
-
message._to_dict(),
|
|
91
|
+
data=message._to_dict(),
|
|
92
92
|
files=[fp.path for fp in message.attachments] if message.attachments else None)
|
|
93
93
|
|
|
94
94
|
self._update(data)
|
|
@@ -110,7 +110,7 @@ class Message(DataModel):
|
|
|
110
110
|
await self._http.request(
|
|
111
111
|
'POST',
|
|
112
112
|
f"/channels/{self.channel_id}/messages",
|
|
113
|
-
message._to_dict(),
|
|
113
|
+
data=message._to_dict(),
|
|
114
114
|
files=[fp.path for fp in message.attachments] if message.attachments else None)
|
|
115
115
|
|
|
116
116
|
async def crosspost(self):
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
discord/__init__.py,sha256=cETkxHmm0s9YkSJgn-1daQhnbL96fuD7L9SIg2t5vBg,6823
|
|
2
|
-
discord/client.py,sha256=
|
|
2
|
+
discord/client.py,sha256=feTk5CXgYEMeV9bd3ahz2TcrjqtjRElVRBGU6wwzaoY,14225
|
|
3
3
|
discord/client_like.py,sha256=JyJq0XBq0vKuPBJ_ZnYf5yAAuX1zz_2B1TZBQE-BYbQ,473
|
|
4
4
|
discord/config.py,sha256=OH1A2mNKhDlGvQYASEsVUx2pNxP1YQ2a7a7z-IM5xFg,200
|
|
5
5
|
discord/error.py,sha256=AlislRTna554cM6KC0KrwKugzYDYtx_9C8_3QFe4XDc,2070
|
|
6
|
-
discord/gateway.py,sha256=
|
|
7
|
-
discord/http.py,sha256=
|
|
6
|
+
discord/gateway.py,sha256=L0SE7N29rg02JtNv670JMbw8LMLvtEsLF4LPM33OlHM,5110
|
|
7
|
+
discord/http.py,sha256=EfE_4iwGxeCr1MRga-cVvn4rlkBx5C6MkUgq8yGp-Xs,7751
|
|
8
8
|
discord/intents.py,sha256=Lf2fogFFDqilZeKJv7tcUgKmMW3D7ykK4bBNi-zDzYA,2866
|
|
9
|
-
discord/logger.py,sha256=
|
|
9
|
+
discord/logger.py,sha256=7lks8VyU538nUr_OfiUXRFOtXlpOLZSLvLoDRxJ8loY,4929
|
|
10
10
|
discord/model.py,sha256=CmuxyoWLWokE_UvCQ9M7U9Cr7JH9R7ULMv9KMwzXjDQ,3105
|
|
11
11
|
discord/dispatch/__init__.py,sha256=m7ixrbNhOV9QRORXPw6LSwxofQMAvLmPFBweBZu9ACc,20
|
|
12
|
-
discord/dispatch/command_dispatcher.py,sha256=
|
|
13
|
-
discord/dispatch/event_dispatcher.py,sha256=
|
|
14
|
-
discord/dispatch/prefix_dispatcher.py,sha256=
|
|
12
|
+
discord/dispatch/command_dispatcher.py,sha256=4yyw-PspQrKWG40oSWOSz_SmmqG2D2HTpRmaVi19H2Q,5954
|
|
13
|
+
discord/dispatch/event_dispatcher.py,sha256=0hX4oSQloxColXNxPT2iWnqi4S9VKTxyD9_MSPsVYUM,3769
|
|
14
|
+
discord/dispatch/prefix_dispatcher.py,sha256=ikGlYddZawtQ8eVSsbWph-vqh3xc3aheTfOBg7zDeSM,2273
|
|
15
15
|
discord/events/__init__.py,sha256=xE8YtJ7NKZkm7MLnohDQIbezh3ColmLR-3BMiZabt3k,18
|
|
16
16
|
discord/events/channel_events.py,sha256=t9UL4JjDqulAP_XepQ8MRMW54pNRqCbIK3M8tauzf9I,1556
|
|
17
17
|
discord/events/guild_events.py,sha256=Ok9tW3tjcwtbiqJgbe-42d9-R3-2RzqmIgBHEP-2Pcc,896
|
|
@@ -42,13 +42,13 @@ discord/parts/role.py,sha256=cK96UdgT-kU0gY5C_1LZXPrYg144x2RDmGjT28so57A,920
|
|
|
42
42
|
discord/resources/__init__.py,sha256=EdzYKftSLqqr3Bpzc0_90kfozJXOtp9jNTIHhCTt_-0,21
|
|
43
43
|
discord/resources/application.py,sha256=vYMTli_FSbC7venMepsJ9bkzdEQVkKYpnxCJ9K2XDho,2765
|
|
44
44
|
discord/resources/bot_emojis.py,sha256=RvGCSOBkjS39P2aab0FzYUOTzBOiHX99RLrJZzAYNiU,1701
|
|
45
|
-
discord/resources/channel.py,sha256=
|
|
46
|
-
discord/resources/guild.py,sha256=
|
|
47
|
-
discord/resources/interaction.py,sha256=
|
|
48
|
-
discord/resources/message.py,sha256=
|
|
45
|
+
discord/resources/channel.py,sha256=HC7PCtjP8mjyEZSOhYqoiqJaGbawZA4XOL3GkTlWScA,6920
|
|
46
|
+
discord/resources/guild.py,sha256=jVlkdeMfB97q9qbgOn1g3Wtt0BcL9Ktj4hbbATnnbus,8234
|
|
47
|
+
discord/resources/interaction.py,sha256=sUg5HOZLZvb_LWwHwyfPlcgMMuIXrNpvjVwR2efwVDk,5937
|
|
48
|
+
discord/resources/message.py,sha256=Oo3-EbJMt0UHK77x3CndjYwRrWbp5Vf5UHSIJqXzNHE,7542
|
|
49
49
|
discord/resources/user.py,sha256=vk89TnCVi-6ZgbDs_TZTCXrx_NfFS5Q9Wi_itYoaoyg,3085
|
|
50
|
-
scurrypy-0.4.dist-info/licenses/LICENSE,sha256=qIlBETYpSEU8glbiwiJbuDxVl-2WIuf1PDqJemMjKkc,792
|
|
51
|
-
scurrypy-0.4.dist-info/METADATA,sha256=
|
|
52
|
-
scurrypy-0.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
53
|
-
scurrypy-0.4.dist-info/top_level.txt,sha256=fJkrNbR-_8ubMBUcDEJBcfkpECrvSEmMrNKgvLlQFoM,8
|
|
54
|
-
scurrypy-0.4.dist-info/RECORD,,
|
|
50
|
+
scurrypy-0.4.1.dist-info/licenses/LICENSE,sha256=qIlBETYpSEU8glbiwiJbuDxVl-2WIuf1PDqJemMjKkc,792
|
|
51
|
+
scurrypy-0.4.1.dist-info/METADATA,sha256=6VFko4N476slDA-Rel1ZPKvghS4NKpUZcvzpb3HSb08,4797
|
|
52
|
+
scurrypy-0.4.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
53
|
+
scurrypy-0.4.1.dist-info/top_level.txt,sha256=fJkrNbR-_8ubMBUcDEJBcfkpECrvSEmMrNKgvLlQFoM,8
|
|
54
|
+
scurrypy-0.4.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|