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.

Files changed (52) hide show
  1. discord/__init__.py +9 -0
  2. discord/client.py +312 -0
  3. discord/dispatch/__init__.py +1 -0
  4. discord/dispatch/command_dispatcher.py +156 -0
  5. discord/dispatch/event_dispatcher.py +85 -0
  6. discord/dispatch/prefix_dispatcher.py +53 -0
  7. discord/error.py +63 -0
  8. discord/events/__init__.py +33 -0
  9. discord/events/channel_events.py +52 -0
  10. discord/events/guild_events.py +38 -0
  11. discord/events/hello_event.py +9 -0
  12. discord/events/interaction_events.py +145 -0
  13. discord/events/message_events.py +43 -0
  14. discord/events/reaction_events.py +99 -0
  15. discord/events/ready_event.py +30 -0
  16. discord/gateway.py +175 -0
  17. discord/http.py +292 -0
  18. discord/intents.py +87 -0
  19. discord/logger.py +147 -0
  20. discord/model.py +88 -0
  21. discord/models/__init__.py +8 -0
  22. discord/models/application.py +37 -0
  23. discord/models/emoji.py +34 -0
  24. discord/models/guild.py +35 -0
  25. discord/models/integration.py +23 -0
  26. discord/models/member.py +27 -0
  27. discord/models/role.py +53 -0
  28. discord/models/user.py +15 -0
  29. discord/parts/__init__.py +28 -0
  30. discord/parts/action_row.py +258 -0
  31. discord/parts/attachment.py +18 -0
  32. discord/parts/channel.py +20 -0
  33. discord/parts/command.py +102 -0
  34. discord/parts/component_types.py +5 -0
  35. discord/parts/components_v2.py +270 -0
  36. discord/parts/embed.py +154 -0
  37. discord/parts/message.py +179 -0
  38. discord/parts/modal.py +21 -0
  39. discord/parts/role.py +39 -0
  40. discord/resources/__init__.py +10 -0
  41. discord/resources/application.py +94 -0
  42. discord/resources/bot_emojis.py +49 -0
  43. discord/resources/channel.py +192 -0
  44. discord/resources/guild.py +265 -0
  45. discord/resources/interaction.py +155 -0
  46. discord/resources/message.py +223 -0
  47. discord/resources/user.py +111 -0
  48. scurrypy-0.1.0.dist-info/METADATA +8 -0
  49. scurrypy-0.1.0.dist-info/RECORD +52 -0
  50. scurrypy-0.1.0.dist-info/WHEEL +5 -0
  51. scurrypy-0.1.0.dist-info/licenses/LICENSE +5 -0
  52. 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,8 @@
1
+ # discord/models
2
+
3
+ from .application import ApplicationModel
4
+ from .emoji import EmojiModel
5
+ from .guild import GuildModel
6
+ from .member import MemberModel
7
+ from .user import UserModel
8
+ from .role import RoleModel
@@ -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."""
@@ -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}")