scurrypy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of scurrypy might be problematic. Click here for more details.
- discord/__init__.py +9 -0
- discord/client.py +312 -0
- discord/dispatch/__init__.py +1 -0
- discord/dispatch/command_dispatcher.py +156 -0
- discord/dispatch/event_dispatcher.py +85 -0
- discord/dispatch/prefix_dispatcher.py +53 -0
- discord/error.py +63 -0
- discord/events/__init__.py +33 -0
- discord/events/channel_events.py +52 -0
- discord/events/guild_events.py +38 -0
- discord/events/hello_event.py +9 -0
- discord/events/interaction_events.py +145 -0
- discord/events/message_events.py +43 -0
- discord/events/reaction_events.py +99 -0
- discord/events/ready_event.py +30 -0
- discord/gateway.py +175 -0
- discord/http.py +292 -0
- discord/intents.py +87 -0
- discord/logger.py +147 -0
- discord/model.py +88 -0
- discord/models/__init__.py +8 -0
- discord/models/application.py +37 -0
- discord/models/emoji.py +34 -0
- discord/models/guild.py +35 -0
- discord/models/integration.py +23 -0
- discord/models/member.py +27 -0
- discord/models/role.py +53 -0
- discord/models/user.py +15 -0
- discord/parts/__init__.py +28 -0
- discord/parts/action_row.py +258 -0
- discord/parts/attachment.py +18 -0
- discord/parts/channel.py +20 -0
- discord/parts/command.py +102 -0
- discord/parts/component_types.py +5 -0
- discord/parts/components_v2.py +270 -0
- discord/parts/embed.py +154 -0
- discord/parts/message.py +179 -0
- discord/parts/modal.py +21 -0
- discord/parts/role.py +39 -0
- discord/resources/__init__.py +10 -0
- discord/resources/application.py +94 -0
- discord/resources/bot_emojis.py +49 -0
- discord/resources/channel.py +192 -0
- discord/resources/guild.py +265 -0
- discord/resources/interaction.py +155 -0
- discord/resources/message.py +223 -0
- discord/resources/user.py +111 -0
- scurrypy-0.1.0.dist-info/METADATA +8 -0
- scurrypy-0.1.0.dist-info/RECORD +52 -0
- scurrypy-0.1.0.dist-info/WHEEL +5 -0
- scurrypy-0.1.0.dist-info/licenses/LICENSE +5 -0
- scurrypy-0.1.0.dist-info/top_level.txt +1 -0
discord/http.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
import aiofiles
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
import json
|
|
6
|
+
import ssl
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
|
|
10
|
+
from .logger import Logger
|
|
11
|
+
from .error import DiscordError
|
|
12
|
+
|
|
13
|
+
ssl_ctx = ssl.create_default_context()
|
|
14
|
+
ssl_ctx.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 # Disable old SSL
|
|
15
|
+
|
|
16
|
+
DISCORD_HTTP_CODES = {
|
|
17
|
+
200: "Successful Request",
|
|
18
|
+
201: "Successful Creation",
|
|
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."""
|
|
63
|
+
|
|
64
|
+
class HTTPClient:
|
|
65
|
+
"""Handles all HTTP communication with Discord's API
|
|
66
|
+
including rate-limiting by bucket and globally, async request handling,
|
|
67
|
+
multipart/form-data file uploads, and error handling/retries.
|
|
68
|
+
"""
|
|
69
|
+
def __init__(self, token: str, logger: Logger):
|
|
70
|
+
self.token = token
|
|
71
|
+
"""The bot's token."""
|
|
72
|
+
|
|
73
|
+
self._logger = logger
|
|
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()
|
|
107
|
+
self.global_lock = asyncio.Lock()
|
|
108
|
+
self.pending_worker = asyncio.create_task(self._pending_worker())
|
|
109
|
+
self._logger.log_debug("Session started.")
|
|
110
|
+
|
|
111
|
+
async def request(self, method: str, endpoint: str, data=None, params=None, files=None):
|
|
112
|
+
"""Enqueues request WRT rate-limit buckets.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
method (str): HTTP method (e.g., POST, GET, DELETE, PATCH, etc.)
|
|
116
|
+
endpoint (str): Discord endpoint (e.g., /channels/123/messages)
|
|
117
|
+
data (dict, optional): relevant data
|
|
118
|
+
files (list[str], optional): relevant files
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
(Future): future with response
|
|
122
|
+
"""
|
|
123
|
+
url = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}" # normalize for single slashes
|
|
124
|
+
if params:
|
|
125
|
+
url += f"?{urlencode(params)}"
|
|
126
|
+
|
|
127
|
+
future = asyncio.get_event_loop().create_future()
|
|
128
|
+
|
|
129
|
+
if endpoint in self.endpoint_to_bucket:
|
|
130
|
+
bucket = self.endpoint_to_bucket[endpoint]
|
|
131
|
+
if bucket not in self.bucket_queues:
|
|
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))
|
|
139
|
+
|
|
140
|
+
return await future
|
|
141
|
+
|
|
142
|
+
async def _pending_worker(self):
|
|
143
|
+
"""Processes requests from global pending queue."""
|
|
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.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
bucket (str): endpoint
|
|
157
|
+
"""
|
|
158
|
+
queue = self.bucket_queues[bucket].queue
|
|
159
|
+
while True:
|
|
160
|
+
item = await queue.get()
|
|
161
|
+
if item is self._sentinel:
|
|
162
|
+
queue.task_done()
|
|
163
|
+
break
|
|
164
|
+
await self._process_request(item)
|
|
165
|
+
queue.task_done()
|
|
166
|
+
|
|
167
|
+
async def _process_request(self, item: RequestItem):
|
|
168
|
+
"""Core request execution. Handles headers, payload, files, retries, and bucket assignment.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
item (RequestItem): incoming request
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
DiscordError: discord error object
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
await self._check_global_limit()
|
|
178
|
+
|
|
179
|
+
headers = {"Authorization": f"Bot {self.token}"}
|
|
180
|
+
|
|
181
|
+
# Build multipart if files exist
|
|
182
|
+
request_kwargs = {'headers': headers, 'ssl': ssl_ctx}
|
|
183
|
+
|
|
184
|
+
if item.files: # only create FormData if files exist
|
|
185
|
+
form = aiohttp.FormData()
|
|
186
|
+
form.add_field('payload_json', json.dumps(item.data))
|
|
187
|
+
|
|
188
|
+
for idx, file_path in enumerate(item.files):
|
|
189
|
+
try:
|
|
190
|
+
async with aiofiles.open(file_path, 'rb') as f:
|
|
191
|
+
data = await f.read()
|
|
192
|
+
form.add_field(
|
|
193
|
+
f'files[{idx}]',
|
|
194
|
+
data,
|
|
195
|
+
filename=file_path.split('/')[-1],
|
|
196
|
+
content_type='application/octet-stream'
|
|
197
|
+
)
|
|
198
|
+
except FileNotFoundError:
|
|
199
|
+
self._logger.log_warn(f"File '{file_path}' could not be found.")
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
request_kwargs['data'] = form
|
|
203
|
+
|
|
204
|
+
elif item.data is not None:
|
|
205
|
+
request_kwargs['json'] = item.data # aiohttp sets Content-Type automatically
|
|
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)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
except Exception as e:
|
|
246
|
+
if not item.future.done():
|
|
247
|
+
item.future.set_exception(e)
|
|
248
|
+
|
|
249
|
+
async def _handle_response(self, item: RequestItem, resp: aiohttp.ClientResponse):
|
|
250
|
+
"""Resolves future with parsed JSON/text response.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
item (RequestItem): request data to handle
|
|
254
|
+
resp (aiohttp.ClientResponse): response for item
|
|
255
|
+
"""
|
|
256
|
+
if resp.status == 204:
|
|
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)
|
|
264
|
+
|
|
265
|
+
async def _check_global_limit(self):
|
|
266
|
+
"""Waits if the global rate-limit is in effect."""
|
|
267
|
+
async with self.global_lock:
|
|
268
|
+
now = time.time()
|
|
269
|
+
if now < self.global_reset:
|
|
270
|
+
await asyncio.sleep(self.global_reset - now)
|
|
271
|
+
|
|
272
|
+
async def close_session(self):
|
|
273
|
+
"""Gracefully shuts down all workes and closes aiohttp session."""
|
|
274
|
+
# Stop all bucket workers
|
|
275
|
+
for q in self.bucket_queues.values():
|
|
276
|
+
await q.queue.put(self._sentinel)
|
|
277
|
+
|
|
278
|
+
# Stop pending worker
|
|
279
|
+
await self.pending_queue.put(self._sentinel)
|
|
280
|
+
|
|
281
|
+
# Cancel all tasks except the current one
|
|
282
|
+
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
|
283
|
+
for task in tasks:
|
|
284
|
+
task.cancel()
|
|
285
|
+
|
|
286
|
+
# Wait for tasks to acknowledge cancellation
|
|
287
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
288
|
+
|
|
289
|
+
# Close the aiohttp session
|
|
290
|
+
if self.session and not self.session.closed:
|
|
291
|
+
await self.session.close()
|
|
292
|
+
self._logger.log_debug("Session closed successfully.")
|
discord/intents.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from typing import TypedDict, Unpack
|
|
2
|
+
|
|
3
|
+
class Intents:
|
|
4
|
+
"""Gateway intent flags (bitwise).
|
|
5
|
+
For an exhaustive list what intents let your bot listen to what events, see <a href="https://discord.com/developers/docs/events/gateway#list-of-intents" target="_blank" rel="noopener">list of intents</a>.
|
|
6
|
+
|
|
7
|
+
!!! note
|
|
8
|
+
Not all intents are listed. Intents not listed are not yet supported.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
GUILDS = 1 << 0
|
|
12
|
+
"""Receive events related to guilds."""
|
|
13
|
+
|
|
14
|
+
GUILD_MEMBERS = 1 << 1
|
|
15
|
+
"""
|
|
16
|
+
!!! warning "Privileged Intent"
|
|
17
|
+
Requires the app setting `Server Members Intent` to be toggled.
|
|
18
|
+
Receive events related to guild members.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
GUILD_EMOJIS_AND_STICKERS = 1 << 3
|
|
22
|
+
"""Receive events related to custom emojis and stickers."""
|
|
23
|
+
|
|
24
|
+
GUILD_INTEGRATIONS = 1 << 4
|
|
25
|
+
"""Receive events related to integrations within a guild."""
|
|
26
|
+
|
|
27
|
+
GUILD_WEBHOOKS = 1 << 5
|
|
28
|
+
"""Track webhook events within a guild."""
|
|
29
|
+
|
|
30
|
+
GUILD_MESSAGES = 1 << 9
|
|
31
|
+
"""Receive events about messages within a guild."""
|
|
32
|
+
|
|
33
|
+
GUILD_MESSAGE_REACTIONS = 1 << 10
|
|
34
|
+
"""Track changes in reactions on messages."""
|
|
35
|
+
|
|
36
|
+
MESSAGE_CONTENT = 1 << 15
|
|
37
|
+
"""
|
|
38
|
+
!!! warning "Privileged Intent"
|
|
39
|
+
Requires the app setting `Message Content Intent` to be toggled.
|
|
40
|
+
Access content of messages.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
DEFAULT = GUILDS | GUILD_MESSAGES
|
|
44
|
+
|
|
45
|
+
class IntentFlagParams(TypedDict, total=False):
|
|
46
|
+
"""Gateway intent selection parameters."""
|
|
47
|
+
guilds: bool
|
|
48
|
+
guild_members: bool
|
|
49
|
+
guild_emojis_and_stickers: bool
|
|
50
|
+
guild_integrations: bool
|
|
51
|
+
guild_webhooks: bool
|
|
52
|
+
guild_messages: bool
|
|
53
|
+
guild_message_reactions: bool
|
|
54
|
+
message_content: bool
|
|
55
|
+
|
|
56
|
+
def set_intents(**flags: Unpack[IntentFlagParams]):
|
|
57
|
+
"""Set bot intents using [`IntentFlagParams`][discord.intents.IntentFlagParams].
|
|
58
|
+
`Intents.DEFAULT` = (GUILDS | GUILD_MESSAGES) will also be set.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
flags (Unpack[IntentFlagParams]): intents to set. (set respective flag to True to toggle.)
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
(ValueError): invalid flag
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
(int): combined intents field
|
|
68
|
+
"""
|
|
69
|
+
_flag_map = {
|
|
70
|
+
'guilds': Intents.GUILDS,
|
|
71
|
+
'guild_members': Intents.GUILD_MEMBERS,
|
|
72
|
+
'guild_emojis_and_stickers': Intents.GUILD_EMOJIS_AND_STICKERS,
|
|
73
|
+
'guild_integrations': Intents.GUILD_INTEGRATIONS,
|
|
74
|
+
'guild_webhooks': Intents.GUILD_WEBHOOKS,
|
|
75
|
+
'guild_messages': Intents.GUILD_MESSAGES,
|
|
76
|
+
'guild_message_reactions': Intents.GUILD_MESSAGE_REACTIONS,
|
|
77
|
+
'message_content': Intents.MESSAGE_CONTENT
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
intents = Intents.DEFAULT
|
|
81
|
+
for name, value in flags.items():
|
|
82
|
+
if name not in _flag_map:
|
|
83
|
+
raise ValueError(f"Invalid flag: {name}")
|
|
84
|
+
if value:
|
|
85
|
+
intents |= _flag_map[name]
|
|
86
|
+
|
|
87
|
+
return intents
|
discord/logger.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
import copy
|
|
3
|
+
|
|
4
|
+
class Logger:
|
|
5
|
+
"""A utility class for logging messages, supporting log levels, color-coded console output,
|
|
6
|
+
optional file logging, and redaction of sensitive information.
|
|
7
|
+
"""
|
|
8
|
+
DEBUG = '\033[36m'
|
|
9
|
+
"""Debug color: CYCAN"""
|
|
10
|
+
|
|
11
|
+
INFO = '\033[32m'
|
|
12
|
+
"""Info color: GREEN"""
|
|
13
|
+
|
|
14
|
+
WARNING = '\033[33m'
|
|
15
|
+
"""Warning color: YELLOW"""
|
|
16
|
+
|
|
17
|
+
ERROR = '\033[31m'
|
|
18
|
+
"""Error color: RED"""
|
|
19
|
+
|
|
20
|
+
CRITICAL = '\033[41m'
|
|
21
|
+
"""Critical color: RED HIGHLIGHT"""
|
|
22
|
+
|
|
23
|
+
TIME = '\033[90m'
|
|
24
|
+
"""Timestamp color: GRAY"""
|
|
25
|
+
|
|
26
|
+
RESET = '\033[0m'
|
|
27
|
+
"""Reset color: DEFAULT"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, dev_mode: bool = False, quiet: bool = False):
|
|
30
|
+
"""Initializes logger. Opens log file 'bot.log' for writing.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
dev_mode (bool, optional): toggle debug messages. Defaults to False.
|
|
34
|
+
quiet: (bool, optional): supress low-priority logs (INFO, DEBUG, WARN). Defaults to False.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
self.fp = open('bot.log', 'w', encoding="utf-8")
|
|
38
|
+
"""Log file for writing."""
|
|
39
|
+
except Exception as e:
|
|
40
|
+
self.log_error(f"Error {type(e)}: {e}")
|
|
41
|
+
|
|
42
|
+
self.dev_mode = dev_mode
|
|
43
|
+
"""If debug logs should be printed."""
|
|
44
|
+
|
|
45
|
+
self.quiet = quiet
|
|
46
|
+
"""If only high-level logs should be printed."""
|
|
47
|
+
|
|
48
|
+
def now(self):
|
|
49
|
+
"""Returns current timestamp
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
(str): timestamp formatted in YYYY-MM-DD HH:MM:SS
|
|
53
|
+
"""
|
|
54
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
55
|
+
|
|
56
|
+
def _log(self, level: str, color: str, message: str):
|
|
57
|
+
"""Internal helper that writes formatted log to both file and console.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
level (str): DEBUG, INFO, WARN, CRITICAL, ERROR
|
|
61
|
+
color (str): color specified by Logger properties
|
|
62
|
+
message (str): descriptive message to log
|
|
63
|
+
"""
|
|
64
|
+
if self.quiet and level not in ('ERROR', 'CRITICAL', 'HIGH'):
|
|
65
|
+
return # suppress lower-level logs in quiet mode
|
|
66
|
+
|
|
67
|
+
date = self.now()
|
|
68
|
+
self.fp.write(f"[{date}] {level}: {message}\n")
|
|
69
|
+
self.fp.flush()
|
|
70
|
+
print(f"{self.TIME}[{date}]{self.RESET} {color}{level}:{self.RESET} {message}\n")
|
|
71
|
+
|
|
72
|
+
def log_debug(self, message: str):
|
|
73
|
+
"""Logs a debug-level message.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
message (str): descriptive message to log
|
|
77
|
+
"""
|
|
78
|
+
if self.dev_mode == True:
|
|
79
|
+
self._log("DEBUG", self.DEBUG, message)
|
|
80
|
+
|
|
81
|
+
def log_info(self, message: str):
|
|
82
|
+
"""Logs a info-level message.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
message (str): descriptive message to log
|
|
86
|
+
"""
|
|
87
|
+
self._log("INFO", self.INFO, message)
|
|
88
|
+
|
|
89
|
+
def log_warn(self, message: str):
|
|
90
|
+
"""Logs a warn-level message.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
message (str): descriptive message to log
|
|
94
|
+
"""
|
|
95
|
+
self._log("WARN", self.WARNING, message)
|
|
96
|
+
|
|
97
|
+
def log_error(self, message: str):
|
|
98
|
+
"""Logs a error-level message.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
message (str): descriptive message to log
|
|
102
|
+
"""
|
|
103
|
+
self._log("ERROR", self.ERROR, message)
|
|
104
|
+
|
|
105
|
+
def log_critical(self, message: str):
|
|
106
|
+
"""Logs a critical-level message.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
message (str): descriptive message to log
|
|
110
|
+
"""
|
|
111
|
+
self._log("CRITICAL", self.CRITICAL, message)
|
|
112
|
+
|
|
113
|
+
def log_high_priority(self, message: str):
|
|
114
|
+
"""Always log this, regardless of quiet/dev_mode.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
message (str): descriptive message to log
|
|
118
|
+
"""
|
|
119
|
+
self._log("HIGH", self.INFO, message)
|
|
120
|
+
|
|
121
|
+
def redact(self, data: dict, replacement: str ='*** REDACTED ***'):
|
|
122
|
+
"""Recusively redact sensitive fields (token, password, authorization, api_key) from data.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
data (_type_): JSON to sanitize
|
|
126
|
+
replacement (str, optional): what sensitive data is replaced with. Defaults to '*** REDACTED ***'.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
(dict): sanitized JSON
|
|
130
|
+
"""
|
|
131
|
+
keys_to_redact = ['token', 'password', 'authorization', 'api_key']
|
|
132
|
+
|
|
133
|
+
def _redact(obj):
|
|
134
|
+
if isinstance(obj, dict):
|
|
135
|
+
return {
|
|
136
|
+
k: (replacement if k.lower() in keys_to_redact else _redact(v))
|
|
137
|
+
for k, v in obj.items()
|
|
138
|
+
}
|
|
139
|
+
elif isinstance(obj, list):
|
|
140
|
+
return [_redact(item) for item in obj]
|
|
141
|
+
return obj
|
|
142
|
+
|
|
143
|
+
return _redact(copy.deepcopy(data))
|
|
144
|
+
|
|
145
|
+
def close(self):
|
|
146
|
+
"""Closes the log file."""
|
|
147
|
+
self.fp.close()
|
discord/model.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from dataclasses import dataclass, fields, is_dataclass
|
|
2
|
+
from typing import get_args, get_origin, Union
|
|
3
|
+
|
|
4
|
+
from .http import HTTPClient
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class DataModel:
|
|
8
|
+
"""DataModel is a base class for Discord JSONs that provides hydration from raw dicts,
|
|
9
|
+
optional field defaults, and access to HTTP-bound methods.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def from_dict(cls, data: dict, http: HTTPClient = None):
|
|
14
|
+
"""Hydrates the given data into the dataclass child.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
data (dict): JSON data
|
|
18
|
+
http (HTTPClient, optional): HTTP session for requests
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
(dataclass): hydrated dataclass
|
|
22
|
+
"""
|
|
23
|
+
kwargs = {}
|
|
24
|
+
|
|
25
|
+
def unwrap_optional(typ):
|
|
26
|
+
"""Remove NoneType from Optional or leave Union as-is."""
|
|
27
|
+
if get_origin(typ) is Union:
|
|
28
|
+
args = tuple(a for a in get_args(typ) if a is not type(None))
|
|
29
|
+
if len(args) == 1:
|
|
30
|
+
return args[0] # single type left
|
|
31
|
+
else:
|
|
32
|
+
return Union[args] # multi-type union remains
|
|
33
|
+
return typ
|
|
34
|
+
|
|
35
|
+
for field in fields(cls):
|
|
36
|
+
# property must be in given json!
|
|
37
|
+
value = data.get(field.name)
|
|
38
|
+
|
|
39
|
+
inner_type = unwrap_optional(field.type)
|
|
40
|
+
|
|
41
|
+
# Handle None
|
|
42
|
+
if value is None:
|
|
43
|
+
kwargs[field.name] = None
|
|
44
|
+
# Integers stored as strings
|
|
45
|
+
elif isinstance(value, str) and value.isdigit():
|
|
46
|
+
kwargs[field.name] = int(value)
|
|
47
|
+
# Nested dataclass
|
|
48
|
+
elif is_dataclass(inner_type):
|
|
49
|
+
kwargs[field.name] = inner_type.from_dict(value, http)
|
|
50
|
+
# List type
|
|
51
|
+
elif get_origin(inner_type) is list:
|
|
52
|
+
list_type = get_args(inner_type)[0]
|
|
53
|
+
kwargs[field.name] = [
|
|
54
|
+
list_type.from_dict(v, http) if is_dataclass(list_type) else v
|
|
55
|
+
for v in value
|
|
56
|
+
]
|
|
57
|
+
# Everything else (primitive, Union of primitives)
|
|
58
|
+
else:
|
|
59
|
+
kwargs[field.name] = value
|
|
60
|
+
|
|
61
|
+
instance = cls(**kwargs)
|
|
62
|
+
|
|
63
|
+
# attach HTTP if given
|
|
64
|
+
if http:
|
|
65
|
+
instance._http = http
|
|
66
|
+
|
|
67
|
+
return instance
|
|
68
|
+
|
|
69
|
+
def _to_dict(self):
|
|
70
|
+
"""Recursively turns the dataclass into a dictionary and drops empty fields.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
(dict): serialized dataclasss
|
|
74
|
+
"""
|
|
75
|
+
def serialize(value):
|
|
76
|
+
if isinstance(value, list):
|
|
77
|
+
return [serialize(v) for v in value]
|
|
78
|
+
if isinstance(value, DataModel):
|
|
79
|
+
return value._to_dict()
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
result = {}
|
|
83
|
+
for f in fields(self):
|
|
84
|
+
value = getattr(self, f.name)
|
|
85
|
+
if value not in (None, [], {}, "", 0):
|
|
86
|
+
result[f.name] = serialize(value)
|
|
87
|
+
|
|
88
|
+
return result
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from ..model import DataModel
|
|
3
|
+
from .user import UserModel
|
|
4
|
+
from .guild import GuildModel
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ApplicationModel(DataModel):
|
|
8
|
+
"""Represents a bot application object."""
|
|
9
|
+
id: int
|
|
10
|
+
"""ID of the app."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
"""Name of the app."""
|
|
14
|
+
|
|
15
|
+
icon: str
|
|
16
|
+
"""Icon hash of the app."""
|
|
17
|
+
|
|
18
|
+
description: str
|
|
19
|
+
"""Description of the app."""
|
|
20
|
+
|
|
21
|
+
bot_public: bool
|
|
22
|
+
"""If other users can add this app to a guild."""
|
|
23
|
+
|
|
24
|
+
bot: UserModel
|
|
25
|
+
"""Partial user obhect for the bot user associated with the app."""
|
|
26
|
+
|
|
27
|
+
owner: UserModel
|
|
28
|
+
"""Partial user object for the owner of the app."""
|
|
29
|
+
|
|
30
|
+
guild_id: int
|
|
31
|
+
"""Guild ID associated with the app (e.g., a support server)."""
|
|
32
|
+
|
|
33
|
+
guild: GuildModel
|
|
34
|
+
"""Partial guild object of the associated guild."""
|
|
35
|
+
|
|
36
|
+
approximate_guild_count: int
|
|
37
|
+
"""Approximate guild member count."""
|
discord/models/emoji.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from ..model import DataModel
|
|
3
|
+
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class EmojiModel(DataModel):
|
|
8
|
+
"""Represents a Discord emoji."""
|
|
9
|
+
name: str
|
|
10
|
+
"""Name of emoji."""
|
|
11
|
+
|
|
12
|
+
id: int = 0
|
|
13
|
+
"""ID of the emoji (if custom)."""
|
|
14
|
+
|
|
15
|
+
animated: bool = False
|
|
16
|
+
"""If the emoji is animated. Defaults to `False`."""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def mention(self) -> str:
|
|
20
|
+
"""For use in message content."""
|
|
21
|
+
return f"<a:{self.name}:{self.id}>" if self.animated else f"<:{self.name}:{self.id}>"
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def api_code(self) -> str:
|
|
25
|
+
"""Return the correct API code for this emoji (URL-safe)."""
|
|
26
|
+
if not self.id:
|
|
27
|
+
# unicode emoji
|
|
28
|
+
return quote(self.name)
|
|
29
|
+
|
|
30
|
+
# custom emoji
|
|
31
|
+
if self.animated:
|
|
32
|
+
return quote(f"a:{self.name}:{self.id}")
|
|
33
|
+
|
|
34
|
+
return quote(f"{self.name}:{self.id}")
|