disagreement 0.2.0rc1__py3-none-any.whl → 0.4.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.
Files changed (38) hide show
  1. disagreement/__init__.py +2 -4
  2. disagreement/audio.py +42 -5
  3. disagreement/cache.py +43 -4
  4. disagreement/caching.py +121 -0
  5. disagreement/client.py +1682 -1535
  6. disagreement/enums.py +10 -3
  7. disagreement/error_handler.py +5 -1
  8. disagreement/errors.py +1341 -3
  9. disagreement/event_dispatcher.py +3 -5
  10. disagreement/ext/__init__.py +1 -0
  11. disagreement/ext/app_commands/__init__.py +0 -2
  12. disagreement/ext/app_commands/commands.py +0 -2
  13. disagreement/ext/app_commands/context.py +0 -2
  14. disagreement/ext/app_commands/converters.py +2 -4
  15. disagreement/ext/app_commands/decorators.py +5 -7
  16. disagreement/ext/app_commands/handler.py +1 -3
  17. disagreement/ext/app_commands/hybrid.py +0 -2
  18. disagreement/ext/commands/__init__.py +63 -61
  19. disagreement/ext/commands/cog.py +0 -2
  20. disagreement/ext/commands/converters.py +16 -5
  21. disagreement/ext/commands/core.py +728 -563
  22. disagreement/ext/commands/decorators.py +294 -219
  23. disagreement/ext/commands/errors.py +0 -2
  24. disagreement/ext/commands/help.py +0 -2
  25. disagreement/ext/commands/view.py +1 -3
  26. disagreement/gateway.py +632 -586
  27. disagreement/http.py +1362 -1041
  28. disagreement/interactions.py +0 -2
  29. disagreement/models.py +2682 -2263
  30. disagreement/shard_manager.py +0 -2
  31. disagreement/ui/view.py +167 -165
  32. disagreement/voice_client.py +263 -162
  33. {disagreement-0.2.0rc1.dist-info → disagreement-0.4.0.dist-info}/METADATA +33 -6
  34. disagreement-0.4.0.dist-info/RECORD +55 -0
  35. disagreement-0.2.0rc1.dist-info/RECORD +0 -54
  36. {disagreement-0.2.0rc1.dist-info → disagreement-0.4.0.dist-info}/WHEEL +0 -0
  37. {disagreement-0.2.0rc1.dist-info → disagreement-0.4.0.dist-info}/licenses/LICENSE +0 -0
  38. {disagreement-0.2.0rc1.dist-info → disagreement-0.4.0.dist-info}/top_level.txt +0 -0
disagreement/models.py CHANGED
@@ -1,2263 +1,2682 @@
1
- # disagreement/models.py
2
-
3
- """
4
- Data models for Discord objects.
5
- """
6
-
7
- import asyncio
8
- import json
9
- from dataclasses import dataclass
10
- from typing import Any, AsyncIterator, Dict, List, Optional, TYPE_CHECKING, Union
11
-
12
- import aiohttp # pylint: disable=import-error
13
- from .color import Color
14
- from .errors import DisagreementException, HTTPException
15
- from .enums import ( # These enums will need to be defined in disagreement/enums.py
16
- VerificationLevel,
17
- MessageNotificationLevel,
18
- ExplicitContentFilterLevel,
19
- MFALevel,
20
- GuildNSFWLevel,
21
- PremiumTier,
22
- GuildFeature,
23
- ChannelType,
24
- ComponentType,
25
- ButtonStyle, # Added for Button
26
- GuildScheduledEventPrivacyLevel,
27
- GuildScheduledEventStatus,
28
- GuildScheduledEventEntityType,
29
- # SelectMenuType will be part of ComponentType or a new enum if needed
30
- )
31
- from .permissions import Permissions
32
-
33
-
34
- if TYPE_CHECKING:
35
- from .client import Client # For type hinting to avoid circular imports
36
- from .enums import OverwriteType # For PermissionOverwrite model
37
- from .ui.view import View
38
- from .interactions import Snowflake
39
-
40
- # Forward reference Message if it were used in type hints before its definition
41
- # from .models import Message # Not needed as Message is defined before its use in TextChannel.send etc.
42
- from .components import component_factory
43
-
44
-
45
- class User:
46
- """Represents a Discord User.
47
-
48
- Attributes:
49
- id (str): The user's unique ID.
50
- username (str): The user's username.
51
- discriminator (str): The user's 4-digit discord-tag.
52
- bot (bool): Whether the user belongs to an OAuth2 application. Defaults to False.
53
- avatar (Optional[str]): The user's avatar hash, if any.
54
- """
55
-
56
- def __init__(self, data: dict):
57
- self.id: str = data["id"]
58
- self.username: str = data["username"]
59
- self.discriminator: str = data["discriminator"]
60
- self.bot: bool = data.get("bot", False)
61
- self.avatar: Optional[str] = data.get("avatar")
62
-
63
- @property
64
- def mention(self) -> str:
65
- """str: Returns a string that allows you to mention the user."""
66
- return f"<@{self.id}>"
67
-
68
- def __repr__(self) -> str:
69
- return f"<User id='{self.id}' username='{self.username}' discriminator='{self.discriminator}'>"
70
-
71
-
72
- class Message:
73
- """Represents a message sent in a channel on Discord.
74
-
75
- Attributes:
76
- id (str): The message's unique ID.
77
- channel_id (str): The ID of the channel the message was sent in.
78
- guild_id (Optional[str]): The ID of the guild the message was sent in, if applicable.
79
- author (User): The user who sent the message.
80
- content (str): The actual content of the message.
81
- timestamp (str): When this message was sent (ISO8601 timestamp).
82
- components (Optional[List[ActionRow]]): Structured components attached
83
- to the message if present.
84
- attachments (List[Attachment]): Attachments included with the message.
85
- """
86
-
87
- def __init__(self, data: dict, client_instance: "Client"):
88
- self._client: "Client" = (
89
- client_instance # Store reference to client for methods like reply
90
- )
91
-
92
- self.id: str = data["id"]
93
- self.channel_id: str = data["channel_id"]
94
- self.guild_id: Optional[str] = data.get("guild_id")
95
- self.author: User = User(data["author"])
96
- self.content: str = data["content"]
97
- self.timestamp: str = data["timestamp"]
98
- if data.get("components"):
99
- self.components: Optional[List[ActionRow]] = [
100
- ActionRow.from_dict(c, client_instance)
101
- for c in data.get("components", [])
102
- ]
103
- else:
104
- self.components = None
105
- self.attachments: List[Attachment] = [
106
- Attachment(a) for a in data.get("attachments", [])
107
- ]
108
- # Add other fields as needed, e.g., attachments, embeds, reactions, etc.
109
- # self.mentions: List[User] = [User(u) for u in data.get("mentions", [])]
110
- # self.mention_roles: List[str] = data.get("mention_roles", [])
111
- # self.mention_everyone: bool = data.get("mention_everyone", False)
112
-
113
- async def reply(
114
- self,
115
- content: Optional[str] = None,
116
- *, # Make additional params keyword-only
117
- tts: bool = False,
118
- embed: Optional["Embed"] = None,
119
- embeds: Optional[List["Embed"]] = None,
120
- components: Optional[List["ActionRow"]] = None,
121
- allowed_mentions: Optional[Dict[str, Any]] = None,
122
- mention_author: Optional[bool] = None,
123
- flags: Optional[int] = None,
124
- view: Optional["View"] = None,
125
- ) -> "Message":
126
- """|coro|
127
-
128
- Sends a reply to the message.
129
- This is a shorthand for `Client.send_message` in the message's channel.
130
-
131
- Parameters:
132
- content (Optional[str]): The content of the message.
133
- tts (bool): Whether the message should be sent with text-to-speech.
134
- embed (Optional[Embed]): A single embed to send. Cannot be used with `embeds`.
135
- embeds (Optional[List[Embed]]): A list of embeds to send.
136
- components (Optional[List[ActionRow]]): A list of ActionRow components.
137
- allowed_mentions (Optional[Dict[str, Any]]): Allowed mentions for the message.
138
- mention_author (Optional[bool]): Whether to mention the author in the reply. If ``None`` the
139
- client's :attr:`mention_replies` setting is used.
140
- flags (Optional[int]): Message flags.
141
- view (Optional[View]): A view to send with the message.
142
-
143
- Returns:
144
- Message: The message that was sent.
145
-
146
- Raises:
147
- HTTPException: Sending the message failed.
148
- ValueError: If both `embed` and `embeds` are provided.
149
- """
150
- # Determine allowed mentions for the reply
151
- if mention_author is None:
152
- mention_author = getattr(self._client, "mention_replies", False)
153
-
154
- if allowed_mentions is None:
155
- allowed_mentions = {"replied_user": mention_author}
156
- else:
157
- allowed_mentions = dict(allowed_mentions)
158
- allowed_mentions.setdefault("replied_user", mention_author)
159
-
160
- # Client.send_message is already updated to handle these parameters
161
- return await self._client.send_message(
162
- channel_id=self.channel_id,
163
- content=content,
164
- tts=tts,
165
- embed=embed,
166
- embeds=embeds,
167
- components=components,
168
- allowed_mentions=allowed_mentions,
169
- message_reference={
170
- "message_id": self.id,
171
- "channel_id": self.channel_id,
172
- "guild_id": self.guild_id,
173
- },
174
- flags=flags,
175
- view=view,
176
- )
177
-
178
- async def edit(
179
- self,
180
- *,
181
- content: Optional[str] = None,
182
- embed: Optional["Embed"] = None,
183
- embeds: Optional[List["Embed"]] = None,
184
- components: Optional[List["ActionRow"]] = None,
185
- allowed_mentions: Optional[Dict[str, Any]] = None,
186
- flags: Optional[int] = None,
187
- view: Optional["View"] = None,
188
- ) -> "Message":
189
- """|coro|
190
-
191
- Edits this message.
192
-
193
- Parameters are the same as :meth:`Client.edit_message`.
194
- """
195
-
196
- return await self._client.edit_message(
197
- channel_id=self.channel_id,
198
- message_id=self.id,
199
- content=content,
200
- embed=embed,
201
- embeds=embeds,
202
- components=components,
203
- allowed_mentions=allowed_mentions,
204
- flags=flags,
205
- view=view,
206
- )
207
-
208
- async def add_reaction(self, emoji: str) -> None:
209
- """|coro| Add a reaction to this message."""
210
-
211
- await self._client.add_reaction(self.channel_id, self.id, emoji)
212
-
213
- async def remove_reaction(self, emoji: str) -> None:
214
- """|coro| Remove the bot's reaction from this message."""
215
-
216
- await self._client.remove_reaction(self.channel_id, self.id, emoji)
217
-
218
- async def clear_reactions(self) -> None:
219
- """|coro| Remove all reactions from this message."""
220
-
221
- await self._client.clear_reactions(self.channel_id, self.id)
222
-
223
- async def delete(self, delay: Optional[float] = None) -> None:
224
- """|coro|
225
-
226
- Deletes this message.
227
-
228
- Parameters
229
- ----------
230
- delay:
231
- If provided, wait this many seconds before deleting.
232
- """
233
-
234
- if delay is not None:
235
- await asyncio.sleep(delay)
236
-
237
- await self._client._http.delete_message(self.channel_id, self.id)
238
-
239
- def __repr__(self) -> str:
240
- return f"<Message id='{self.id}' channel_id='{self.channel_id}' author='{self.author!r}'>"
241
-
242
-
243
- class EmbedFooter:
244
- """Represents an embed footer."""
245
-
246
- def __init__(self, data: Dict[str, Any]):
247
- self.text: str = data["text"]
248
- self.icon_url: Optional[str] = data.get("icon_url")
249
- self.proxy_icon_url: Optional[str] = data.get("proxy_icon_url")
250
-
251
- def to_dict(self) -> Dict[str, Any]:
252
- payload = {"text": self.text}
253
- if self.icon_url:
254
- payload["icon_url"] = self.icon_url
255
- if self.proxy_icon_url:
256
- payload["proxy_icon_url"] = self.proxy_icon_url
257
- return payload
258
-
259
-
260
- class EmbedImage:
261
- """Represents an embed image."""
262
-
263
- def __init__(self, data: Dict[str, Any]):
264
- self.url: str = data["url"]
265
- self.proxy_url: Optional[str] = data.get("proxy_url")
266
- self.height: Optional[int] = data.get("height")
267
- self.width: Optional[int] = data.get("width")
268
-
269
- def to_dict(self) -> Dict[str, Any]:
270
- payload: Dict[str, Any] = {"url": self.url}
271
- if self.proxy_url:
272
- payload["proxy_url"] = self.proxy_url
273
- if self.height:
274
- payload["height"] = self.height
275
- if self.width:
276
- payload["width"] = self.width
277
- return payload
278
-
279
- def __repr__(self) -> str:
280
- return f"<EmbedImage url='{self.url}'>"
281
-
282
-
283
- class EmbedThumbnail(EmbedImage): # Similar structure to EmbedImage
284
- """Represents an embed thumbnail."""
285
-
286
- pass
287
-
288
-
289
- class EmbedAuthor:
290
- """Represents an embed author."""
291
-
292
- def __init__(self, data: Dict[str, Any]):
293
- self.name: str = data["name"]
294
- self.url: Optional[str] = data.get("url")
295
- self.icon_url: Optional[str] = data.get("icon_url")
296
- self.proxy_icon_url: Optional[str] = data.get("proxy_icon_url")
297
-
298
- def to_dict(self) -> Dict[str, Any]:
299
- payload = {"name": self.name}
300
- if self.url:
301
- payload["url"] = self.url
302
- if self.icon_url:
303
- payload["icon_url"] = self.icon_url
304
- if self.proxy_icon_url:
305
- payload["proxy_icon_url"] = self.proxy_icon_url
306
- return payload
307
-
308
-
309
- class EmbedField:
310
- """Represents an embed field."""
311
-
312
- def __init__(self, data: Dict[str, Any]):
313
- self.name: str = data["name"]
314
- self.value: str = data["value"]
315
- self.inline: bool = data.get("inline", False)
316
-
317
- def to_dict(self) -> Dict[str, Any]:
318
- return {"name": self.name, "value": self.value, "inline": self.inline}
319
-
320
-
321
- class Embed:
322
- """Represents a Discord embed.
323
-
324
- Attributes can be set directly or via methods like `set_author`, `add_field`.
325
- """
326
-
327
- def __init__(self, data: Optional[Dict[str, Any]] = None):
328
- data = data or {}
329
- self.title: Optional[str] = data.get("title")
330
- self.type: str = data.get("type", "rich") # Default to "rich" for sending
331
- self.description: Optional[str] = data.get("description")
332
- self.url: Optional[str] = data.get("url")
333
- self.timestamp: Optional[str] = data.get("timestamp") # ISO8601 timestamp
334
- self.color = Color.parse(data.get("color"))
335
-
336
- self.footer: Optional[EmbedFooter] = (
337
- EmbedFooter(data["footer"]) if data.get("footer") else None
338
- )
339
- self.image: Optional[EmbedImage] = (
340
- EmbedImage(data["image"]) if data.get("image") else None
341
- )
342
- self.thumbnail: Optional[EmbedThumbnail] = (
343
- EmbedThumbnail(data["thumbnail"]) if data.get("thumbnail") else None
344
- )
345
- # Video and Provider are less common for bot-sent embeds, can be added if needed.
346
- self.author: Optional[EmbedAuthor] = (
347
- EmbedAuthor(data["author"]) if data.get("author") else None
348
- )
349
- self.fields: List[EmbedField] = (
350
- [EmbedField(f) for f in data["fields"]] if data.get("fields") else []
351
- )
352
-
353
- def to_dict(self) -> Dict[str, Any]:
354
- payload: Dict[str, Any] = {"type": self.type}
355
- if self.title:
356
- payload["title"] = self.title
357
- if self.description:
358
- payload["description"] = self.description
359
- if self.url:
360
- payload["url"] = self.url
361
- if self.timestamp:
362
- payload["timestamp"] = self.timestamp
363
- if self.color is not None:
364
- payload["color"] = self.color.value
365
- if self.footer:
366
- payload["footer"] = self.footer.to_dict()
367
- if self.image:
368
- payload["image"] = self.image.to_dict()
369
- if self.thumbnail:
370
- payload["thumbnail"] = self.thumbnail.to_dict()
371
- if self.author:
372
- payload["author"] = self.author.to_dict()
373
- if self.fields:
374
- payload["fields"] = [f.to_dict() for f in self.fields]
375
- return payload
376
-
377
- # Convenience methods for building embeds can be added here
378
- # e.g., set_author, add_field, set_footer, set_image, etc.
379
-
380
-
381
- class Attachment:
382
- """Represents a message attachment."""
383
-
384
- def __init__(self, data: Dict[str, Any]):
385
- self.id: str = data["id"]
386
- self.filename: str = data["filename"]
387
- self.description: Optional[str] = data.get("description")
388
- self.content_type: Optional[str] = data.get("content_type")
389
- self.size: Optional[int] = data.get("size")
390
- self.url: Optional[str] = data.get("url")
391
- self.proxy_url: Optional[str] = data.get("proxy_url")
392
- self.height: Optional[int] = data.get("height") # If image
393
- self.width: Optional[int] = data.get("width") # If image
394
- self.ephemeral: bool = data.get("ephemeral", False)
395
-
396
- def __repr__(self) -> str:
397
- return f"<Attachment id='{self.id}' filename='{self.filename}'>"
398
-
399
- def to_dict(self) -> Dict[str, Any]:
400
- payload: Dict[str, Any] = {"id": self.id, "filename": self.filename}
401
- if self.description is not None:
402
- payload["description"] = self.description
403
- if self.content_type is not None:
404
- payload["content_type"] = self.content_type
405
- if self.size is not None:
406
- payload["size"] = self.size
407
- if self.url is not None:
408
- payload["url"] = self.url
409
- if self.proxy_url is not None:
410
- payload["proxy_url"] = self.proxy_url
411
- if self.height is not None:
412
- payload["height"] = self.height
413
- if self.width is not None:
414
- payload["width"] = self.width
415
- if self.ephemeral:
416
- payload["ephemeral"] = self.ephemeral
417
- return payload
418
-
419
-
420
- class File:
421
- """Represents a file to be uploaded."""
422
-
423
- def __init__(self, filename: str, data: bytes):
424
- self.filename = filename
425
- self.data = data
426
-
427
-
428
- class AllowedMentions:
429
- """Represents allowed mentions for a message or interaction response."""
430
-
431
- def __init__(self, data: Dict[str, Any]):
432
- self.parse: List[str] = data.get("parse", [])
433
- self.roles: List[str] = data.get("roles", [])
434
- self.users: List[str] = data.get("users", [])
435
- self.replied_user: bool = data.get("replied_user", False)
436
-
437
- def to_dict(self) -> Dict[str, Any]:
438
- payload: Dict[str, Any] = {"parse": self.parse}
439
- if self.roles:
440
- payload["roles"] = self.roles
441
- if self.users:
442
- payload["users"] = self.users
443
- if self.replied_user:
444
- payload["replied_user"] = self.replied_user
445
- return payload
446
-
447
-
448
- class RoleTags:
449
- """Represents tags for a role."""
450
-
451
- def __init__(self, data: Dict[str, Any]):
452
- self.bot_id: Optional[str] = data.get("bot_id")
453
- self.integration_id: Optional[str] = data.get("integration_id")
454
- self.premium_subscriber: Optional[bool] = (
455
- data.get("premium_subscriber") is None
456
- ) # presence of null value means true
457
-
458
- def to_dict(self) -> Dict[str, Any]:
459
- payload = {}
460
- if self.bot_id:
461
- payload["bot_id"] = self.bot_id
462
- if self.integration_id:
463
- payload["integration_id"] = self.integration_id
464
- if self.premium_subscriber:
465
- payload["premium_subscriber"] = None # Explicitly null
466
- return payload
467
-
468
-
469
- class Role:
470
- """Represents a Discord Role."""
471
-
472
- def __init__(self, data: Dict[str, Any]):
473
- self.id: str = data["id"]
474
- self.name: str = data["name"]
475
- self.color: int = data["color"]
476
- self.hoist: bool = data["hoist"]
477
- self.icon: Optional[str] = data.get("icon")
478
- self.unicode_emoji: Optional[str] = data.get("unicode_emoji")
479
- self.position: int = data["position"]
480
- self.permissions: str = data["permissions"] # String of bitwise permissions
481
- self.managed: bool = data["managed"]
482
- self.mentionable: bool = data["mentionable"]
483
- self.tags: Optional[RoleTags] = (
484
- RoleTags(data["tags"]) if data.get("tags") else None
485
- )
486
-
487
- @property
488
- def mention(self) -> str:
489
- """str: Returns a string that allows you to mention the role."""
490
- return f"<@&{self.id}>"
491
-
492
- def __repr__(self) -> str:
493
- return f"<Role id='{self.id}' name='{self.name}'>"
494
-
495
-
496
- class Member(User): # Member inherits from User
497
- """Represents a Guild Member.
498
- This class combines User attributes with guild-specific Member attributes.
499
- """
500
-
501
- def __init__(
502
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
503
- ):
504
- self._client: Optional["Client"] = client_instance
505
- self.guild_id: Optional[str] = None
506
- # User part is nested under 'user' key in member data from gateway/API
507
- user_data = data.get("user", {})
508
- # If 'id' is not in user_data but is top-level (e.g. from interaction resolved member without user object)
509
- if "id" not in user_data and "id" in data:
510
- # This case is less common for full member objects but can happen.
511
- # We'd need to construct a partial user from top-level member fields if 'user' is missing.
512
- # For now, assume 'user' object is present for full Member hydration.
513
- # If 'user' is missing, the User part might be incomplete.
514
- pass # User fields will be missing or default if 'user' not in data.
515
-
516
- super().__init__(
517
- user_data if user_data else data
518
- ) # Pass user_data or data if user_data is empty
519
-
520
- self.nick: Optional[str] = data.get("nick")
521
- self.avatar: Optional[str] = data.get("avatar") # Guild-specific avatar hash
522
- self.roles: List[str] = data.get("roles", []) # List of role IDs
523
- self.joined_at: str = data["joined_at"] # ISO8601 timestamp
524
- self.premium_since: Optional[str] = data.get(
525
- "premium_since"
526
- ) # ISO8601 timestamp
527
- self.deaf: bool = data.get("deaf", False)
528
- self.mute: bool = data.get("mute", False)
529
- self.pending: bool = data.get("pending", False)
530
- self.permissions: Optional[str] = data.get(
531
- "permissions"
532
- ) # Permissions in the channel, if applicable
533
- self.communication_disabled_until: Optional[str] = data.get(
534
- "communication_disabled_until"
535
- ) # ISO8601 timestamp
536
-
537
- # If 'user' object was present, ensure User attributes are from there
538
- if user_data:
539
- self.id = user_data.get("id", self.id) # Prefer user.id if available
540
- self.username = user_data.get("username", self.username)
541
- self.discriminator = user_data.get("discriminator", self.discriminator)
542
- self.bot = user_data.get("bot", self.bot)
543
- # User's global avatar is User.avatar, Member.avatar is guild-specific
544
- # super() already set self.avatar from user_data if present.
545
- # The self.avatar = data.get("avatar") line above overwrites it with guild avatar. This is correct.
546
-
547
- def __repr__(self) -> str:
548
- return f"<Member id='{self.id}' username='{self.username}' nick='{self.nick}'>"
549
-
550
- @property
551
- def display_name(self) -> str:
552
- """Return the nickname if set, otherwise the username."""
553
-
554
- return self.nick or self.username
555
-
556
- async def kick(self, *, reason: Optional[str] = None) -> None:
557
- if not self.guild_id or not self._client:
558
- raise DisagreementException("Member.kick requires guild_id and client")
559
- await self._client._http.kick_member(self.guild_id, self.id, reason=reason)
560
-
561
- async def ban(
562
- self,
563
- *,
564
- delete_message_seconds: int = 0,
565
- reason: Optional[str] = None,
566
- ) -> None:
567
- if not self.guild_id or not self._client:
568
- raise DisagreementException("Member.ban requires guild_id and client")
569
- await self._client._http.ban_member(
570
- self.guild_id,
571
- self.id,
572
- delete_message_seconds=delete_message_seconds,
573
- reason=reason,
574
- )
575
-
576
- async def timeout(
577
- self, until: Optional[str], *, reason: Optional[str] = None
578
- ) -> None:
579
- if not self.guild_id or not self._client:
580
- raise DisagreementException("Member.timeout requires guild_id and client")
581
- await self._client._http.timeout_member(
582
- self.guild_id,
583
- self.id,
584
- until=until,
585
- reason=reason,
586
- )
587
-
588
- @property
589
- def top_role(self) -> Optional["Role"]:
590
- """Return the member's highest role from the guild cache."""
591
-
592
- if not self.guild_id or not self._client:
593
- return None
594
-
595
- guild = self._client.get_guild(self.guild_id)
596
- if not guild:
597
- return None
598
-
599
- if not guild.roles and hasattr(self._client, "fetch_roles"):
600
- try:
601
- self._client.loop.run_until_complete(
602
- self._client.fetch_roles(self.guild_id)
603
- )
604
- except RuntimeError:
605
- future = asyncio.run_coroutine_threadsafe(
606
- self._client.fetch_roles(self.guild_id), self._client.loop
607
- )
608
- future.result()
609
-
610
- role_objects = [r for r in guild.roles if r.id in self.roles]
611
- if not role_objects:
612
- return None
613
-
614
- return max(role_objects, key=lambda r: r.position)
615
-
616
-
617
- class PartialEmoji:
618
- """Represents a partial emoji, often used in components or reactions.
619
-
620
- This typically means only id, name, and animated are known.
621
- For unicode emojis, id will be None and name will be the unicode character.
622
- """
623
-
624
- def __init__(self, data: Dict[str, Any]):
625
- self.id: Optional[str] = data.get("id")
626
- self.name: Optional[str] = data.get(
627
- "name"
628
- ) # Can be None for unknown custom emoji, or unicode char
629
- self.animated: bool = data.get("animated", False)
630
-
631
- def to_dict(self) -> Dict[str, Any]:
632
- payload: Dict[str, Any] = {}
633
- if self.id:
634
- payload["id"] = self.id
635
- if self.name:
636
- payload["name"] = self.name
637
- if self.animated: # Only include if true, as per some Discord patterns
638
- payload["animated"] = self.animated
639
- return payload
640
-
641
- def __str__(self) -> str:
642
- if self.id:
643
- return f"<{'a' if self.animated else ''}:{self.name}:{self.id}>"
644
- return self.name or "" # For unicode emoji
645
-
646
- def __repr__(self) -> str:
647
- return (
648
- f"<PartialEmoji id='{self.id}' name='{self.name}' animated={self.animated}>"
649
- )
650
-
651
-
652
- def to_partial_emoji(
653
- value: Union[str, "PartialEmoji", None],
654
- ) -> Optional["PartialEmoji"]:
655
- """Convert a string or PartialEmoji to a PartialEmoji instance.
656
-
657
- Args:
658
- value: Either a unicode emoji string, a :class:`PartialEmoji`, or ``None``.
659
-
660
- Returns:
661
- A :class:`PartialEmoji` or ``None`` if ``value`` was ``None``.
662
-
663
- Raises:
664
- TypeError: If ``value`` is not ``str`` or :class:`PartialEmoji`.
665
- """
666
-
667
- if value is None or isinstance(value, PartialEmoji):
668
- return value
669
- if isinstance(value, str):
670
- return PartialEmoji({"name": value, "id": None})
671
- raise TypeError("emoji must be a str or PartialEmoji")
672
-
673
-
674
- class Emoji(PartialEmoji):
675
- """Represents a custom guild emoji.
676
-
677
- Inherits id, name, animated from PartialEmoji.
678
- """
679
-
680
- def __init__(
681
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
682
- ):
683
- super().__init__(data)
684
- self._client: Optional["Client"] = (
685
- client_instance # For potential future methods
686
- )
687
-
688
- # Roles this emoji is whitelisted to
689
- self.roles: List[str] = data.get("roles", []) # List of role IDs
690
-
691
- # User object for the user that created this emoji (optional, only for GUILD_EMOJIS_AND_STICKERS intent)
692
- self.user: Optional[User] = User(data["user"]) if data.get("user") else None
693
-
694
- self.require_colons: bool = data.get("require_colons", False)
695
- self.managed: bool = data.get(
696
- "managed", False
697
- ) # If this emoji is managed by an integration
698
- self.available: bool = data.get(
699
- "available", True
700
- ) # Whether this emoji can be used
701
-
702
- def __repr__(self) -> str:
703
- return f"<Emoji id='{self.id}' name='{self.name}' animated={self.animated} available={self.available}>"
704
-
705
-
706
- class StickerItem:
707
- """Represents a sticker item, a basic representation of a sticker.
708
-
709
- Used in sticker packs and sometimes in message data.
710
- """
711
-
712
- def __init__(self, data: Dict[str, Any]):
713
- self.id: str = data["id"]
714
- self.name: str = data["name"]
715
- self.format_type: int = data["format_type"] # StickerFormatType enum
716
-
717
- def __repr__(self) -> str:
718
- return f"<StickerItem id='{self.id}' name='{self.name}'>"
719
-
720
-
721
- class Sticker(StickerItem):
722
- """Represents a Discord sticker.
723
-
724
- Inherits id, name, format_type from StickerItem.
725
- """
726
-
727
- def __init__(
728
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
729
- ):
730
- super().__init__(data)
731
- self._client: Optional["Client"] = client_instance
732
-
733
- self.pack_id: Optional[str] = data.get(
734
- "pack_id"
735
- ) # For standard stickers, ID of the pack
736
- self.description: Optional[str] = data.get("description")
737
- self.tags: str = data.get(
738
- "tags", ""
739
- ) # Comma-separated list of tags for guild stickers
740
- # type is StickerType enum (STANDARD or GUILD)
741
- # For guild stickers, this is 2. For standard stickers, this is 1.
742
- self.type: int = data["type"]
743
- self.available: bool = data.get(
744
- "available", True
745
- ) # Whether this sticker can be used
746
- self.guild_id: Optional[str] = data.get(
747
- "guild_id"
748
- ) # ID of the guild that owns this sticker
749
-
750
- # User object of the user that uploaded the guild sticker
751
- self.user: Optional[User] = User(data["user"]) if data.get("user") else None
752
-
753
- self.sort_value: Optional[int] = data.get(
754
- "sort_value"
755
- ) # The standard sticker's sort order within its pack
756
-
757
- def __repr__(self) -> str:
758
- return f"<Sticker id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
759
-
760
-
761
- class StickerPack:
762
- """Represents a pack of standard stickers."""
763
-
764
- def __init__(
765
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
766
- ):
767
- self._client: Optional["Client"] = client_instance
768
- self.id: str = data["id"]
769
- self.stickers: List[Sticker] = [
770
- Sticker(s_data, client_instance) for s_data in data.get("stickers", [])
771
- ]
772
- self.name: str = data["name"]
773
- self.sku_id: str = data["sku_id"]
774
- self.cover_sticker_id: Optional[str] = data.get("cover_sticker_id")
775
- self.description: str = data["description"]
776
- self.banner_asset_id: Optional[str] = data.get(
777
- "banner_asset_id"
778
- ) # ID of the pack's banner image
779
-
780
- def __repr__(self) -> str:
781
- return f"<StickerPack id='{self.id}' name='{self.name}' stickers={len(self.stickers)}>"
782
-
783
-
784
- class PermissionOverwrite:
785
- """Represents a permission overwrite for a role or member in a channel."""
786
-
787
- def __init__(self, data: Dict[str, Any]):
788
- self.id: str = data["id"] # Role or user ID
789
- self._type_val: int = int(data["type"]) # Store raw type for enum property
790
- self.allow: str = data["allow"] # Bitwise value of allowed permissions
791
- self.deny: str = data["deny"] # Bitwise value of denied permissions
792
-
793
- @property
794
- def type(self) -> "OverwriteType":
795
- from .enums import (
796
- OverwriteType,
797
- ) # Local import to avoid circularity at module level
798
-
799
- return OverwriteType(self._type_val)
800
-
801
- def to_dict(self) -> Dict[str, Any]:
802
- return {
803
- "id": self.id,
804
- "type": self.type.value,
805
- "allow": self.allow,
806
- "deny": self.deny,
807
- }
808
-
809
- def __repr__(self) -> str:
810
- return f"<PermissionOverwrite id='{self.id}' type='{self.type.name if hasattr(self.type, 'name') else self._type_val}' allow='{self.allow}' deny='{self.deny}'>"
811
-
812
-
813
- class Guild:
814
- """Represents a Discord Guild (Server).
815
-
816
- Attributes:
817
- id (str): Guild ID.
818
- name (str): Guild name (2-100 characters, excluding @, #, :, ```).
819
- icon (Optional[str]): Icon hash.
820
- splash (Optional[str]): Splash hash.
821
- discovery_splash (Optional[str]): Discovery splash hash; only present for discoverable guilds.
822
- owner (Optional[bool]): True if the user is the owner of the guild. (Only for /users/@me/guilds endpoint)
823
- owner_id (str): ID of owner.
824
- permissions (Optional[str]): Total permissions for the user in the guild (excludes overwrites). (Only for /users/@me/guilds endpoint)
825
- afk_channel_id (Optional[str]): ID of afk channel.
826
- afk_timeout (int): AFK timeout in seconds.
827
- widget_enabled (Optional[bool]): True if the server widget is enabled.
828
- widget_channel_id (Optional[str]): The channel id that the widget will generate an invite to, or null if set to no invite.
829
- verification_level (VerificationLevel): Verification level required for the guild.
830
- default_message_notifications (MessageNotificationLevel): Default message notifications level.
831
- explicit_content_filter (ExplicitContentFilterLevel): Explicit content filter level.
832
- roles (List[Role]): Roles in the guild.
833
- emojis (List[Dict]): Custom emojis. (Consider creating an Emoji model)
834
- features (List[GuildFeature]): Enabled guild features.
835
- mfa_level (MFALevel): Required MFA level for the guild.
836
- application_id (Optional[str]): Application ID of the guild creator if it is bot-created.
837
- system_channel_id (Optional[str]): The id of the channel where guild notices such as welcome messages and boost events are posted.
838
- system_channel_flags (int): System channel flags.
839
- rules_channel_id (Optional[str]): The id of the channel where Community guilds can display rules.
840
- max_members (Optional[int]): The maximum number of members for the guild.
841
- vanity_url_code (Optional[str]): The vanity url code for the guild.
842
- description (Optional[str]): The description of a Community guild.
843
- banner (Optional[str]): Banner hash.
844
- premium_tier (PremiumTier): Premium tier (Server Boost level).
845
- premium_subscription_count (Optional[int]): The number of boosts this guild currently has.
846
- preferred_locale (str): The preferred locale of a Community guild. Defaults to "en-US".
847
- public_updates_channel_id (Optional[str]): The id of the channel where admins and moderators of Community guilds receive notices from Discord.
848
- max_video_channel_users (Optional[int]): The maximum number of users in a video channel.
849
- welcome_screen (Optional[Dict]): The welcome screen of a Community guild. (Consider a WelcomeScreen model)
850
- nsfw_level (GuildNSFWLevel): Guild NSFW level.
851
- stickers (Optional[List[Dict]]): Custom stickers in the guild. (Consider a Sticker model)
852
- premium_progress_bar_enabled (bool): Whether the guild has the premium progress bar enabled.
853
- """
854
-
855
- def __init__(self, data: Dict[str, Any], client_instance: "Client"):
856
- self._client: "Client" = client_instance
857
- self.id: str = data["id"]
858
- self.name: str = data["name"]
859
- self.icon: Optional[str] = data.get("icon")
860
- self.splash: Optional[str] = data.get("splash")
861
- self.discovery_splash: Optional[str] = data.get("discovery_splash")
862
- self.owner: Optional[bool] = data.get("owner")
863
- self.owner_id: str = data["owner_id"]
864
- self.permissions: Optional[str] = data.get("permissions")
865
- self.afk_channel_id: Optional[str] = data.get("afk_channel_id")
866
- self.afk_timeout: int = data["afk_timeout"]
867
- self.widget_enabled: Optional[bool] = data.get("widget_enabled")
868
- self.widget_channel_id: Optional[str] = data.get("widget_channel_id")
869
- self.verification_level: VerificationLevel = VerificationLevel(
870
- data["verification_level"]
871
- )
872
- self.default_message_notifications: MessageNotificationLevel = (
873
- MessageNotificationLevel(data["default_message_notifications"])
874
- )
875
- self.explicit_content_filter: ExplicitContentFilterLevel = (
876
- ExplicitContentFilterLevel(data["explicit_content_filter"])
877
- )
878
-
879
- self.roles: List[Role] = [Role(r) for r in data.get("roles", [])]
880
- self.emojis: List[Emoji] = [
881
- Emoji(e_data, client_instance) for e_data in data.get("emojis", [])
882
- ]
883
-
884
- # Assuming GuildFeature can be constructed from string feature names or their values
885
- self.features: List[GuildFeature] = [
886
- GuildFeature(f) if not isinstance(f, GuildFeature) else f
887
- for f in data.get("features", [])
888
- ]
889
-
890
- self.mfa_level: MFALevel = MFALevel(data["mfa_level"])
891
- self.application_id: Optional[str] = data.get("application_id")
892
- self.system_channel_id: Optional[str] = data.get("system_channel_id")
893
- self.system_channel_flags: int = data["system_channel_flags"]
894
- self.rules_channel_id: Optional[str] = data.get("rules_channel_id")
895
- self.max_members: Optional[int] = data.get("max_members")
896
- self.vanity_url_code: Optional[str] = data.get("vanity_url_code")
897
- self.description: Optional[str] = data.get("description")
898
- self.banner: Optional[str] = data.get("banner")
899
- self.premium_tier: PremiumTier = PremiumTier(data["premium_tier"])
900
- self.premium_subscription_count: Optional[int] = data.get(
901
- "premium_subscription_count"
902
- )
903
- self.preferred_locale: str = data.get("preferred_locale", "en-US")
904
- self.public_updates_channel_id: Optional[str] = data.get(
905
- "public_updates_channel_id"
906
- )
907
- self.max_video_channel_users: Optional[int] = data.get(
908
- "max_video_channel_users"
909
- )
910
- self.approximate_member_count: Optional[int] = data.get(
911
- "approximate_member_count"
912
- )
913
- self.approximate_presence_count: Optional[int] = data.get(
914
- "approximate_presence_count"
915
- )
916
- self.welcome_screen: Optional["WelcomeScreen"] = (
917
- WelcomeScreen(data["welcome_screen"], client_instance)
918
- if data.get("welcome_screen")
919
- else None
920
- )
921
- self.nsfw_level: GuildNSFWLevel = GuildNSFWLevel(data["nsfw_level"])
922
- self.stickers: Optional[List[Sticker]] = (
923
- [Sticker(s_data, client_instance) for s_data in data.get("stickers", [])]
924
- if data.get("stickers")
925
- else None
926
- )
927
- self.premium_progress_bar_enabled: bool = data.get(
928
- "premium_progress_bar_enabled", False
929
- )
930
-
931
- # Internal caches, populated by events or specific fetches
932
- self._channels: Dict[str, "Channel"] = {}
933
- self._members: Dict[str, Member] = {}
934
- self._threads: Dict[str, "Thread"] = {}
935
-
936
- def get_channel(self, channel_id: str) -> Optional["Channel"]:
937
- return self._channels.get(channel_id)
938
-
939
- def get_member(self, user_id: str) -> Optional[Member]:
940
- return self._members.get(user_id)
941
-
942
- def get_member_named(self, name: str) -> Optional[Member]:
943
- """Retrieve a cached member by username or nickname.
944
-
945
- The lookup is case-insensitive and searches both the username and
946
- guild nickname for a match.
947
-
948
- Parameters
949
- ----------
950
- name: str
951
- The username or nickname to search for.
952
-
953
- Returns
954
- -------
955
- Optional[Member]
956
- The matching member if found, otherwise ``None``.
957
- """
958
-
959
- lowered = name.lower()
960
- for member in self._members.values():
961
- if member.username.lower() == lowered:
962
- return member
963
- if member.nick and member.nick.lower() == lowered:
964
- return member
965
- return None
966
-
967
- def get_role(self, role_id: str) -> Optional[Role]:
968
- return next((role for role in self.roles if role.id == role_id), None)
969
-
970
- def __repr__(self) -> str:
971
- return f"<Guild id='{self.id}' name='{self.name}'>"
972
-
973
-
974
- class Channel:
975
- """Base class for Discord channels."""
976
-
977
- def __init__(self, data: Dict[str, Any], client_instance: "Client"):
978
- self._client: "Client" = client_instance
979
- self.id: str = data["id"]
980
- self._type_val: int = int(data["type"]) # Store raw type for enum property
981
-
982
- self.guild_id: Optional[str] = data.get("guild_id")
983
- self.name: Optional[str] = data.get("name")
984
- self.position: Optional[int] = data.get("position")
985
- self.permission_overwrites: List["PermissionOverwrite"] = [
986
- PermissionOverwrite(d) for d in data.get("permission_overwrites", [])
987
- ]
988
- self.nsfw: Optional[bool] = data.get("nsfw", False)
989
- self.parent_id: Optional[str] = data.get(
990
- "parent_id"
991
- ) # ID of the parent category channel or thread parent
992
-
993
- @property
994
- def type(self) -> ChannelType:
995
- return ChannelType(self._type_val)
996
-
997
- @property
998
- def mention(self) -> str:
999
- return f"<#{self.id}>"
1000
-
1001
- async def delete(self, reason: Optional[str] = None):
1002
- await self._client._http.delete_channel(self.id, reason=reason)
1003
-
1004
- def __repr__(self) -> str:
1005
- return f"<Channel id='{self.id}' name='{self.name}' type='{self.type.name if hasattr(self.type, 'name') else self._type_val}'>"
1006
-
1007
- def permission_overwrite_for(
1008
- self, target: Union["Role", "Member", str]
1009
- ) -> Optional["PermissionOverwrite"]:
1010
- """Return the :class:`PermissionOverwrite` for ``target`` if present."""
1011
-
1012
- if isinstance(target, str):
1013
- target_id = target
1014
- else:
1015
- target_id = target.id
1016
- for overwrite in self.permission_overwrites:
1017
- if overwrite.id == target_id:
1018
- return overwrite
1019
- return None
1020
-
1021
- @staticmethod
1022
- def _apply_overwrite(
1023
- perms: Permissions, overwrite: Optional["PermissionOverwrite"]
1024
- ) -> Permissions:
1025
- if overwrite is None:
1026
- return perms
1027
-
1028
- perms &= ~Permissions(int(overwrite.deny))
1029
- perms |= Permissions(int(overwrite.allow))
1030
- return perms
1031
-
1032
- def permissions_for(self, member: "Member") -> Permissions:
1033
- """Resolve channel permissions for ``member``."""
1034
-
1035
- if self.guild_id is None:
1036
- return Permissions(~0)
1037
-
1038
- if not hasattr(self._client, "get_guild"):
1039
- return Permissions(0)
1040
-
1041
- guild = self._client.get_guild(self.guild_id)
1042
- if guild is None:
1043
- return Permissions(0)
1044
-
1045
- base = Permissions(0)
1046
-
1047
- everyone = guild.get_role(guild.id)
1048
- if everyone is not None:
1049
- base |= Permissions(int(everyone.permissions))
1050
-
1051
- for rid in member.roles:
1052
- role = guild.get_role(rid)
1053
- if role is not None:
1054
- base |= Permissions(int(role.permissions))
1055
-
1056
- if base & Permissions.ADMINISTRATOR:
1057
- return Permissions(~0)
1058
-
1059
- # Apply @everyone overwrite
1060
- base = self._apply_overwrite(base, self.permission_overwrite_for(guild.id))
1061
-
1062
- # Role overwrites
1063
- role_allow = Permissions(0)
1064
- role_deny = Permissions(0)
1065
- for rid in member.roles:
1066
- ow = self.permission_overwrite_for(rid)
1067
- if ow is not None:
1068
- role_allow |= Permissions(int(ow.allow))
1069
- role_deny |= Permissions(int(ow.deny))
1070
-
1071
- base &= ~role_deny
1072
- base |= role_allow
1073
-
1074
- # Member overwrite
1075
- base = self._apply_overwrite(base, self.permission_overwrite_for(member.id))
1076
-
1077
- return base
1078
-
1079
-
1080
- class TextChannel(Channel):
1081
- """Represents a guild text channel or announcement channel."""
1082
-
1083
- def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1084
- super().__init__(data, client_instance)
1085
- self.topic: Optional[str] = data.get("topic")
1086
- self.last_message_id: Optional[str] = data.get("last_message_id")
1087
- self.rate_limit_per_user: Optional[int] = data.get("rate_limit_per_user", 0)
1088
- self.default_auto_archive_duration: Optional[int] = data.get(
1089
- "default_auto_archive_duration"
1090
- )
1091
- self.last_pin_timestamp: Optional[str] = data.get("last_pin_timestamp")
1092
-
1093
- def history(
1094
- self,
1095
- *,
1096
- limit: Optional[int] = None,
1097
- before: Optional[str] = None,
1098
- after: Optional[str] = None,
1099
- ) -> AsyncIterator["Message"]:
1100
- """Return an async iterator over this channel's messages."""
1101
-
1102
- from .utils import message_pager
1103
-
1104
- return message_pager(self, limit=limit, before=before, after=after)
1105
-
1106
- async def send(
1107
- self,
1108
- content: Optional[str] = None,
1109
- *,
1110
- embed: Optional[Embed] = None,
1111
- embeds: Optional[List[Embed]] = None,
1112
- components: Optional[List["ActionRow"]] = None, # Added components
1113
- ) -> "Message": # Forward reference Message
1114
- if not hasattr(self._client, "send_message"):
1115
- raise NotImplementedError(
1116
- "Client.send_message is required for TextChannel.send"
1117
- )
1118
-
1119
- return await self._client.send_message(
1120
- channel_id=self.id,
1121
- content=content,
1122
- embed=embed,
1123
- embeds=embeds,
1124
- components=components,
1125
- )
1126
-
1127
- async def purge(
1128
- self, limit: int, *, before: "Snowflake | None" = None
1129
- ) -> List["Snowflake"]:
1130
- """Bulk delete messages from this channel."""
1131
-
1132
- params: Dict[str, Union[int, str]] = {"limit": limit}
1133
- if before is not None:
1134
- params["before"] = before
1135
-
1136
- messages = await self._client._http.request(
1137
- "GET", f"/channels/{self.id}/messages", params=params
1138
- )
1139
- ids = [m["id"] for m in messages]
1140
- if not ids:
1141
- return []
1142
-
1143
- await self._client._http.bulk_delete_messages(self.id, ids)
1144
- for mid in ids:
1145
- self._client._messages.pop(mid, None)
1146
- return ids
1147
-
1148
- def __repr__(self) -> str:
1149
- return f"<TextChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
1150
-
1151
-
1152
- class VoiceChannel(Channel):
1153
- """Represents a guild voice channel or stage voice channel."""
1154
-
1155
- def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1156
- super().__init__(data, client_instance)
1157
- self.bitrate: int = data.get("bitrate", 64000)
1158
- self.user_limit: int = data.get("user_limit", 0)
1159
- self.rtc_region: Optional[str] = data.get("rtc_region")
1160
- self.video_quality_mode: Optional[int] = data.get("video_quality_mode")
1161
-
1162
- def __repr__(self) -> str:
1163
- return f"<VoiceChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
1164
-
1165
-
1166
- class StageChannel(VoiceChannel):
1167
- """Represents a guild stage channel."""
1168
-
1169
- def __repr__(self) -> str:
1170
- return f"<StageChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
1171
-
1172
- async def start_stage_instance(
1173
- self,
1174
- topic: str,
1175
- *,
1176
- privacy_level: int = 2,
1177
- reason: Optional[str] = None,
1178
- guild_scheduled_event_id: Optional[str] = None,
1179
- ) -> "StageInstance":
1180
- if not hasattr(self._client, "_http"):
1181
- raise DisagreementException("Client missing HTTP for stage instance")
1182
-
1183
- payload: Dict[str, Any] = {
1184
- "channel_id": self.id,
1185
- "topic": topic,
1186
- "privacy_level": privacy_level,
1187
- }
1188
- if guild_scheduled_event_id is not None:
1189
- payload["guild_scheduled_event_id"] = guild_scheduled_event_id
1190
-
1191
- instance = await self._client._http.start_stage_instance(payload, reason=reason)
1192
- instance._client = self._client
1193
- return instance
1194
-
1195
- async def edit_stage_instance(
1196
- self,
1197
- *,
1198
- topic: Optional[str] = None,
1199
- privacy_level: Optional[int] = None,
1200
- reason: Optional[str] = None,
1201
- ) -> "StageInstance":
1202
- if not hasattr(self._client, "_http"):
1203
- raise DisagreementException("Client missing HTTP for stage instance")
1204
-
1205
- payload: Dict[str, Any] = {}
1206
- if topic is not None:
1207
- payload["topic"] = topic
1208
- if privacy_level is not None:
1209
- payload["privacy_level"] = privacy_level
1210
-
1211
- instance = await self._client._http.edit_stage_instance(
1212
- self.id, payload, reason=reason
1213
- )
1214
- instance._client = self._client
1215
- return instance
1216
-
1217
- async def end_stage_instance(self, *, reason: Optional[str] = None) -> None:
1218
- if not hasattr(self._client, "_http"):
1219
- raise DisagreementException("Client missing HTTP for stage instance")
1220
-
1221
- await self._client._http.end_stage_instance(self.id, reason=reason)
1222
-
1223
-
1224
- class StageInstance:
1225
- """Represents a stage instance."""
1226
-
1227
- def __init__(
1228
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1229
- ) -> None:
1230
- self._client = client_instance
1231
- self.id: str = data["id"]
1232
- self.guild_id: Optional[str] = data.get("guild_id")
1233
- self.channel_id: str = data["channel_id"]
1234
- self.topic: str = data["topic"]
1235
- self.privacy_level: int = data.get("privacy_level", 2)
1236
- self.discoverable_disabled: bool = data.get("discoverable_disabled", False)
1237
- self.guild_scheduled_event_id: Optional[str] = data.get(
1238
- "guild_scheduled_event_id"
1239
- )
1240
-
1241
- def __repr__(self) -> str:
1242
- return f"<StageInstance id='{self.id}' channel_id='{self.channel_id}'>"
1243
-
1244
-
1245
- class CategoryChannel(Channel):
1246
- """Represents a guild category channel."""
1247
-
1248
- def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1249
- super().__init__(data, client_instance)
1250
-
1251
- @property
1252
- def channels(self) -> List[Channel]:
1253
- if not self.guild_id or not hasattr(self._client, "get_guild"):
1254
- return []
1255
- guild = self._client.get_guild(self.guild_id)
1256
- if not guild or not hasattr(
1257
- guild, "_channels"
1258
- ): # Ensure guild and _channels exist
1259
- return []
1260
-
1261
- categorized_channels = [
1262
- ch
1263
- for ch in guild._channels.values()
1264
- if getattr(ch, "parent_id", None) == self.id
1265
- ]
1266
- return sorted(
1267
- categorized_channels,
1268
- key=lambda c: c.position if c.position is not None else -1,
1269
- )
1270
-
1271
- def __repr__(self) -> str:
1272
- return f"<CategoryChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
1273
-
1274
-
1275
- class ThreadMetadata:
1276
- """Represents the metadata of a thread."""
1277
-
1278
- def __init__(self, data: Dict[str, Any]):
1279
- self.archived: bool = data["archived"]
1280
- self.auto_archive_duration: int = data["auto_archive_duration"]
1281
- self.archive_timestamp: str = data["archive_timestamp"]
1282
- self.locked: bool = data["locked"]
1283
- self.invitable: Optional[bool] = data.get("invitable")
1284
- self.create_timestamp: Optional[str] = data.get("create_timestamp")
1285
-
1286
-
1287
- class Thread(TextChannel): # Threads are a specialized TextChannel
1288
- """Represents a Discord Thread."""
1289
-
1290
- def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1291
- super().__init__(data, client_instance) # Handles common text channel fields
1292
- self.owner_id: Optional[str] = data.get("owner_id")
1293
- # parent_id is already handled by base Channel init if present in data
1294
- self.message_count: Optional[int] = data.get("message_count")
1295
- self.member_count: Optional[int] = data.get("member_count")
1296
- self.thread_metadata: ThreadMetadata = ThreadMetadata(data["thread_metadata"])
1297
- self.member: Optional["ThreadMember"] = (
1298
- ThreadMember(data["member"], client_instance)
1299
- if data.get("member")
1300
- else None
1301
- )
1302
-
1303
- def __repr__(self) -> str:
1304
- return (
1305
- f"<Thread id='{self.id}' name='{self.name}' parent_id='{self.parent_id}'>"
1306
- )
1307
-
1308
-
1309
- class DMChannel(Channel):
1310
- """Represents a Direct Message channel."""
1311
-
1312
- def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1313
- super().__init__(data, client_instance)
1314
- self.last_message_id: Optional[str] = data.get("last_message_id")
1315
- self.recipients: List[User] = [
1316
- User(u_data) for u_data in data.get("recipients", [])
1317
- ]
1318
-
1319
- @property
1320
- def recipient(self) -> Optional[User]:
1321
- return self.recipients[0] if self.recipients else None
1322
-
1323
- async def send(
1324
- self,
1325
- content: Optional[str] = None,
1326
- *,
1327
- embed: Optional[Embed] = None,
1328
- embeds: Optional[List[Embed]] = None,
1329
- components: Optional[List["ActionRow"]] = None, # Added components
1330
- ) -> "Message":
1331
- if not hasattr(self._client, "send_message"):
1332
- raise NotImplementedError(
1333
- "Client.send_message is required for DMChannel.send"
1334
- )
1335
-
1336
- return await self._client.send_message(
1337
- channel_id=self.id,
1338
- content=content,
1339
- embed=embed,
1340
- embeds=embeds,
1341
- components=components,
1342
- )
1343
-
1344
- async def history(
1345
- self,
1346
- *,
1347
- limit: Optional[int] = 100,
1348
- before: "Snowflake | None" = None,
1349
- ):
1350
- """An async iterator over messages in this DM."""
1351
-
1352
- params: Dict[str, Union[int, str]] = {}
1353
- if before is not None:
1354
- params["before"] = before
1355
-
1356
- fetched = 0
1357
- while True:
1358
- to_fetch = 100 if limit is None else min(100, limit - fetched)
1359
- if to_fetch <= 0:
1360
- break
1361
- params["limit"] = to_fetch
1362
- messages = await self._client._http.request(
1363
- "GET", f"/channels/{self.id}/messages", params=params.copy()
1364
- )
1365
- if not messages:
1366
- break
1367
- params["before"] = messages[-1]["id"]
1368
- for msg in messages:
1369
- yield Message(msg, self._client)
1370
- fetched += 1
1371
- if limit is not None and fetched >= limit:
1372
- return
1373
-
1374
- def __repr__(self) -> str:
1375
- recipient_repr = self.recipient.username if self.recipient else "Unknown"
1376
- return f"<DMChannel id='{self.id}' recipient='{recipient_repr}'>"
1377
-
1378
-
1379
- class PartialChannel:
1380
- """Represents a partial channel object, often from interactions."""
1381
-
1382
- def __init__(
1383
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1384
- ):
1385
- self._client: Optional["Client"] = client_instance
1386
- self.id: str = data["id"]
1387
- self.name: Optional[str] = data.get("name")
1388
- self._type_val: int = int(data["type"])
1389
- self.permissions: Optional[str] = data.get("permissions")
1390
-
1391
- @property
1392
- def type(self) -> ChannelType:
1393
- return ChannelType(self._type_val)
1394
-
1395
- @property
1396
- def mention(self) -> str:
1397
- return f"<#{self.id}>"
1398
-
1399
- async def fetch_full_channel(self) -> Optional[Channel]:
1400
- if not self._client or not hasattr(self._client, "fetch_channel"):
1401
- # Log or raise if fetching is not possible
1402
- return None
1403
- try:
1404
- # This assumes Client.fetch_channel exists and returns a full Channel object
1405
- return await self._client.fetch_channel(self.id)
1406
- except HTTPException as exc:
1407
- print(f"HTTP error while fetching channel {self.id}: {exc}")
1408
- except (json.JSONDecodeError, KeyError, ValueError) as exc:
1409
- print(f"Failed to parse channel {self.id}: {exc}")
1410
- except DisagreementException as exc:
1411
- print(f"Error fetching channel {self.id}: {exc}")
1412
- return None
1413
-
1414
- def __repr__(self) -> str:
1415
- type_name = self.type.name if hasattr(self.type, "name") else self._type_val
1416
- return f"<PartialChannel id='{self.id}' name='{self.name}' type='{type_name}'>"
1417
-
1418
-
1419
- class Webhook:
1420
- """Represents a Discord Webhook."""
1421
-
1422
- def __init__(
1423
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1424
- ):
1425
- self._client: Optional["Client"] = client_instance
1426
- self.id: str = data["id"]
1427
- self.type: int = int(data.get("type", 1))
1428
- self.guild_id: Optional[str] = data.get("guild_id")
1429
- self.channel_id: Optional[str] = data.get("channel_id")
1430
- self.name: Optional[str] = data.get("name")
1431
- self.avatar: Optional[str] = data.get("avatar")
1432
- self.token: Optional[str] = data.get("token")
1433
- self.application_id: Optional[str] = data.get("application_id")
1434
- self.url: Optional[str] = data.get("url")
1435
- self.user: Optional[User] = User(data["user"]) if data.get("user") else None
1436
-
1437
- def __repr__(self) -> str:
1438
- return f"<Webhook id='{self.id}' name='{self.name}'>"
1439
-
1440
- @classmethod
1441
- def from_url(
1442
- cls, url: str, session: Optional[aiohttp.ClientSession] = None
1443
- ) -> "Webhook":
1444
- """Create a minimal :class:`Webhook` from a webhook URL.
1445
-
1446
- Parameters
1447
- ----------
1448
- url:
1449
- The full Discord webhook URL.
1450
- session:
1451
- Unused for now. Present for API compatibility.
1452
-
1453
- Returns
1454
- -------
1455
- Webhook
1456
- A webhook instance containing only the ``id``, ``token`` and ``url``.
1457
- """
1458
-
1459
- parts = url.rstrip("/").split("/")
1460
- if len(parts) < 2:
1461
- raise ValueError("Invalid webhook URL")
1462
- token = parts[-1]
1463
- webhook_id = parts[-2]
1464
-
1465
- return cls({"id": webhook_id, "token": token, "url": url})
1466
-
1467
- async def send(
1468
- self,
1469
- content: Optional[str] = None,
1470
- *,
1471
- username: Optional[str] = None,
1472
- avatar_url: Optional[str] = None,
1473
- tts: bool = False,
1474
- embed: Optional["Embed"] = None,
1475
- embeds: Optional[List["Embed"]] = None,
1476
- components: Optional[List["ActionRow"]] = None,
1477
- allowed_mentions: Optional[Dict[str, Any]] = None,
1478
- attachments: Optional[List[Any]] = None,
1479
- files: Optional[List[Any]] = None,
1480
- flags: Optional[int] = None,
1481
- ) -> "Message":
1482
- """Send a message using this webhook."""
1483
-
1484
- if not self._client:
1485
- raise DisagreementException("Webhook is not bound to a Client")
1486
- assert self.token is not None, "Webhook token missing"
1487
-
1488
- if embed and embeds:
1489
- raise ValueError("Cannot provide both embed and embeds.")
1490
-
1491
- final_embeds_payload: Optional[List[Dict[str, Any]]] = None
1492
- if embed:
1493
- final_embeds_payload = [embed.to_dict()]
1494
- elif embeds:
1495
- final_embeds_payload = [e.to_dict() for e in embeds]
1496
-
1497
- components_payload: Optional[List[Dict[str, Any]]] = None
1498
- if components:
1499
- components_payload = [c.to_dict() for c in components]
1500
-
1501
- message_data = await self._client._http.execute_webhook(
1502
- self.id,
1503
- self.token,
1504
- content=content,
1505
- tts=tts,
1506
- embeds=final_embeds_payload,
1507
- components=components_payload,
1508
- allowed_mentions=allowed_mentions,
1509
- attachments=attachments,
1510
- files=files,
1511
- flags=flags,
1512
- username=username,
1513
- avatar_url=avatar_url,
1514
- )
1515
-
1516
- return self._client.parse_message(message_data)
1517
-
1518
-
1519
- class GuildTemplate:
1520
- """Represents a guild template."""
1521
-
1522
- def __init__(
1523
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1524
- ):
1525
- self._client = client_instance
1526
- self.code: str = data["code"]
1527
- self.name: str = data["name"]
1528
- self.description: Optional[str] = data.get("description")
1529
- self.usage_count: int = data.get("usage_count", 0)
1530
- self.creator_id: str = data.get("creator_id", "")
1531
- self.creator: Optional[User] = (
1532
- User(data["creator"]) if data.get("creator") else None
1533
- )
1534
- self.created_at: Optional[str] = data.get("created_at")
1535
- self.updated_at: Optional[str] = data.get("updated_at")
1536
- self.source_guild_id: Optional[str] = data.get("source_guild_id")
1537
- self.serialized_source_guild: Dict[str, Any] = data.get(
1538
- "serialized_source_guild", {}
1539
- )
1540
- self.is_dirty: Optional[bool] = data.get("is_dirty")
1541
-
1542
- def __repr__(self) -> str:
1543
- return f"<GuildTemplate code='{self.code}' name='{self.name}'>"
1544
-
1545
-
1546
- # --- Message Components ---
1547
-
1548
-
1549
- class Component:
1550
- """Base class for message components."""
1551
-
1552
- def __init__(self, type: ComponentType):
1553
- self.type: ComponentType = type
1554
- self.custom_id: Optional[str] = None
1555
-
1556
- def to_dict(self) -> Dict[str, Any]:
1557
- payload: Dict[str, Any] = {"type": self.type.value}
1558
- if self.custom_id:
1559
- payload["custom_id"] = self.custom_id
1560
- return payload
1561
-
1562
-
1563
- class ActionRow(Component):
1564
- """Represents an Action Row, a container for other components."""
1565
-
1566
- def __init__(self, components: Optional[List[Component]] = None):
1567
- super().__init__(ComponentType.ACTION_ROW)
1568
- self.components: List[Component] = components or []
1569
-
1570
- def add_component(self, component: Component):
1571
- if isinstance(component, ActionRow):
1572
- raise ValueError("Cannot nest ActionRows inside another ActionRow.")
1573
-
1574
- select_types = {
1575
- ComponentType.STRING_SELECT,
1576
- ComponentType.USER_SELECT,
1577
- ComponentType.ROLE_SELECT,
1578
- ComponentType.MENTIONABLE_SELECT,
1579
- ComponentType.CHANNEL_SELECT,
1580
- }
1581
-
1582
- if component.type in select_types:
1583
- if self.components:
1584
- raise ValueError(
1585
- "Select menu components must be the only component in an ActionRow."
1586
- )
1587
- self.components.append(component)
1588
- return self
1589
-
1590
- if any(c.type in select_types for c in self.components):
1591
- raise ValueError(
1592
- "Cannot add components to an ActionRow that already contains a select menu."
1593
- )
1594
-
1595
- if len(self.components) >= 5:
1596
- raise ValueError("ActionRow cannot have more than 5 components.")
1597
-
1598
- self.components.append(component)
1599
- return self
1600
-
1601
- def to_dict(self) -> Dict[str, Any]:
1602
- payload = super().to_dict()
1603
- payload["components"] = [c.to_dict() for c in self.components]
1604
- return payload
1605
-
1606
- @classmethod
1607
- def from_dict(
1608
- cls, data: Dict[str, Any], client: Optional["Client"] = None
1609
- ) -> "ActionRow":
1610
- """Deserialize an action row payload."""
1611
- from .components import component_factory
1612
-
1613
- row = cls()
1614
- for comp_data in data.get("components", []):
1615
- try:
1616
- row.add_component(component_factory(comp_data, client))
1617
- except Exception:
1618
- # Skip components that fail to parse for now
1619
- continue
1620
- return row
1621
-
1622
-
1623
- class Button(Component):
1624
- """Represents a button component."""
1625
-
1626
- def __init__(
1627
- self,
1628
- *, # Make parameters keyword-only for clarity
1629
- style: ButtonStyle,
1630
- label: Optional[str] = None,
1631
- emoji: Optional["PartialEmoji"] = None, # Changed to PartialEmoji type
1632
- custom_id: Optional[str] = None,
1633
- url: Optional[str] = None,
1634
- disabled: bool = False,
1635
- ):
1636
- super().__init__(ComponentType.BUTTON)
1637
-
1638
- if style == ButtonStyle.LINK and url is None:
1639
- raise ValueError("Link buttons must have a URL.")
1640
- if style != ButtonStyle.LINK and custom_id is None:
1641
- raise ValueError("Non-link buttons must have a custom_id.")
1642
- if label is None and emoji is None:
1643
- raise ValueError("Button must have a label or an emoji.")
1644
-
1645
- self.style: ButtonStyle = style
1646
- self.label: Optional[str] = label
1647
- self.emoji: Optional[PartialEmoji] = emoji
1648
- self.custom_id = custom_id
1649
- self.url: Optional[str] = url
1650
- self.disabled: bool = disabled
1651
-
1652
- def to_dict(self) -> Dict[str, Any]:
1653
- payload = super().to_dict()
1654
- payload["style"] = self.style.value
1655
- if self.label:
1656
- payload["label"] = self.label
1657
- if self.emoji:
1658
- payload["emoji"] = self.emoji.to_dict() # Call to_dict()
1659
- if self.custom_id:
1660
- payload["custom_id"] = self.custom_id
1661
- if self.url:
1662
- payload["url"] = self.url
1663
- if self.disabled:
1664
- payload["disabled"] = self.disabled
1665
- return payload
1666
-
1667
-
1668
- class SelectOption:
1669
- """Represents an option in a select menu."""
1670
-
1671
- def __init__(
1672
- self,
1673
- *, # Make parameters keyword-only
1674
- label: str,
1675
- value: str,
1676
- description: Optional[str] = None,
1677
- emoji: Optional["PartialEmoji"] = None, # Changed to PartialEmoji type
1678
- default: bool = False,
1679
- ):
1680
- self.label: str = label
1681
- self.value: str = value
1682
- self.description: Optional[str] = description
1683
- self.emoji: Optional["PartialEmoji"] = emoji
1684
- self.default: bool = default
1685
-
1686
- def to_dict(self) -> Dict[str, Any]:
1687
- payload: Dict[str, Any] = {
1688
- "label": self.label,
1689
- "value": self.value,
1690
- }
1691
- if self.description:
1692
- payload["description"] = self.description
1693
- if self.emoji:
1694
- payload["emoji"] = self.emoji.to_dict() # Call to_dict()
1695
- if self.default:
1696
- payload["default"] = self.default
1697
- return payload
1698
-
1699
-
1700
- class SelectMenu(Component):
1701
- """Represents a select menu component.
1702
-
1703
- Currently supports STRING_SELECT (type 3).
1704
- User (5), Role (6), Mentionable (7), Channel (8) selects are not yet fully modeled.
1705
- """
1706
-
1707
- def __init__(
1708
- self,
1709
- *, # Make parameters keyword-only
1710
- custom_id: str,
1711
- options: List[SelectOption],
1712
- placeholder: Optional[str] = None,
1713
- min_values: int = 1,
1714
- max_values: int = 1,
1715
- disabled: bool = False,
1716
- channel_types: Optional[List[ChannelType]] = None,
1717
- # For other select types, specific fields would be needed.
1718
- # This constructor primarily targets STRING_SELECT (type 3).
1719
- type: ComponentType = ComponentType.STRING_SELECT, # Default to string select
1720
- ):
1721
- super().__init__(type) # Pass the specific select menu type
1722
-
1723
- if not (1 <= len(options) <= 25):
1724
- raise ValueError("Select menu must have between 1 and 25 options.")
1725
- if not (
1726
- 0 <= min_values <= 25
1727
- ): # Discord docs say min_values can be 0 for some types
1728
- raise ValueError("min_values must be between 0 and 25.")
1729
- if not (1 <= max_values <= 25):
1730
- raise ValueError("max_values must be between 1 and 25.")
1731
- if min_values > max_values:
1732
- raise ValueError("min_values cannot be greater than max_values.")
1733
-
1734
- self.custom_id = custom_id
1735
- self.options: List[SelectOption] = options
1736
- self.placeholder: Optional[str] = placeholder
1737
- self.min_values: int = min_values
1738
- self.max_values: int = max_values
1739
- self.disabled: bool = disabled
1740
- self.channel_types: Optional[List[ChannelType]] = channel_types
1741
-
1742
- def to_dict(self) -> Dict[str, Any]:
1743
- payload = super().to_dict() # Gets {"type": self.type.value}
1744
- payload["custom_id"] = self.custom_id
1745
- payload["options"] = [opt.to_dict() for opt in self.options]
1746
- if self.placeholder:
1747
- payload["placeholder"] = self.placeholder
1748
- payload["min_values"] = self.min_values
1749
- payload["max_values"] = self.max_values
1750
- if self.disabled:
1751
- payload["disabled"] = self.disabled
1752
- if self.type == ComponentType.CHANNEL_SELECT and self.channel_types:
1753
- payload["channel_types"] = [ct.value for ct in self.channel_types]
1754
- return payload
1755
-
1756
-
1757
- class UnfurledMediaItem:
1758
- """Represents an unfurled media item."""
1759
-
1760
- def __init__(
1761
- self,
1762
- url: str,
1763
- proxy_url: Optional[str] = None,
1764
- height: Optional[int] = None,
1765
- width: Optional[int] = None,
1766
- content_type: Optional[str] = None,
1767
- ):
1768
- self.url = url
1769
- self.proxy_url = proxy_url
1770
- self.height = height
1771
- self.width = width
1772
- self.content_type = content_type
1773
-
1774
- def to_dict(self) -> Dict[str, Any]:
1775
- return {
1776
- "url": self.url,
1777
- "proxy_url": self.proxy_url,
1778
- "height": self.height,
1779
- "width": self.width,
1780
- "content_type": self.content_type,
1781
- }
1782
-
1783
-
1784
- class MediaGalleryItem:
1785
- """Represents an item in a media gallery."""
1786
-
1787
- def __init__(
1788
- self,
1789
- media: UnfurledMediaItem,
1790
- description: Optional[str] = None,
1791
- spoiler: bool = False,
1792
- ):
1793
- self.media = media
1794
- self.description = description
1795
- self.spoiler = spoiler
1796
-
1797
- def to_dict(self) -> Dict[str, Any]:
1798
- return {
1799
- "media": self.media.to_dict(),
1800
- "description": self.description,
1801
- "spoiler": self.spoiler,
1802
- }
1803
-
1804
-
1805
- class TextDisplay(Component):
1806
- """Represents a text display component."""
1807
-
1808
- def __init__(self, content: str, id: Optional[int] = None):
1809
- super().__init__(ComponentType.TEXT_DISPLAY)
1810
- self.content = content
1811
- self.id = id
1812
-
1813
- def to_dict(self) -> Dict[str, Any]:
1814
- payload = super().to_dict()
1815
- payload["content"] = self.content
1816
- if self.id is not None:
1817
- payload["id"] = self.id
1818
- return payload
1819
-
1820
-
1821
- class Thumbnail(Component):
1822
- """Represents a thumbnail component."""
1823
-
1824
- def __init__(
1825
- self,
1826
- media: UnfurledMediaItem,
1827
- description: Optional[str] = None,
1828
- spoiler: bool = False,
1829
- id: Optional[int] = None,
1830
- ):
1831
- super().__init__(ComponentType.THUMBNAIL)
1832
- self.media = media
1833
- self.description = description
1834
- self.spoiler = spoiler
1835
- self.id = id
1836
-
1837
- def to_dict(self) -> Dict[str, Any]:
1838
- payload = super().to_dict()
1839
- payload["media"] = self.media.to_dict()
1840
- if self.description:
1841
- payload["description"] = self.description
1842
- if self.spoiler:
1843
- payload["spoiler"] = self.spoiler
1844
- if self.id is not None:
1845
- payload["id"] = self.id
1846
- return payload
1847
-
1848
-
1849
- class Section(Component):
1850
- """Represents a section component."""
1851
-
1852
- def __init__(
1853
- self,
1854
- components: List[TextDisplay],
1855
- accessory: Optional[Union[Thumbnail, Button]] = None,
1856
- id: Optional[int] = None,
1857
- ):
1858
- super().__init__(ComponentType.SECTION)
1859
- self.components = components
1860
- self.accessory = accessory
1861
- self.id = id
1862
-
1863
- def to_dict(self) -> Dict[str, Any]:
1864
- payload = super().to_dict()
1865
- payload["components"] = [c.to_dict() for c in self.components]
1866
- if self.accessory:
1867
- payload["accessory"] = self.accessory.to_dict()
1868
- if self.id is not None:
1869
- payload["id"] = self.id
1870
- return payload
1871
-
1872
-
1873
- class MediaGallery(Component):
1874
- """Represents a media gallery component."""
1875
-
1876
- def __init__(self, items: List[MediaGalleryItem], id: Optional[int] = None):
1877
- super().__init__(ComponentType.MEDIA_GALLERY)
1878
- self.items = items
1879
- self.id = id
1880
-
1881
- def to_dict(self) -> Dict[str, Any]:
1882
- payload = super().to_dict()
1883
- payload["items"] = [i.to_dict() for i in self.items]
1884
- if self.id is not None:
1885
- payload["id"] = self.id
1886
- return payload
1887
-
1888
-
1889
- class FileComponent(Component):
1890
- """Represents a file component."""
1891
-
1892
- def __init__(
1893
- self, file: UnfurledMediaItem, spoiler: bool = False, id: Optional[int] = None
1894
- ):
1895
- super().__init__(ComponentType.FILE)
1896
- self.file = file
1897
- self.spoiler = spoiler
1898
- self.id = id
1899
-
1900
- def to_dict(self) -> Dict[str, Any]:
1901
- payload = super().to_dict()
1902
- payload["file"] = self.file.to_dict()
1903
- if self.spoiler:
1904
- payload["spoiler"] = self.spoiler
1905
- if self.id is not None:
1906
- payload["id"] = self.id
1907
- return payload
1908
-
1909
-
1910
- class Separator(Component):
1911
- """Represents a separator component."""
1912
-
1913
- def __init__(
1914
- self, divider: bool = True, spacing: int = 1, id: Optional[int] = None
1915
- ):
1916
- super().__init__(ComponentType.SEPARATOR)
1917
- self.divider = divider
1918
- self.spacing = spacing
1919
- self.id = id
1920
-
1921
- def to_dict(self) -> Dict[str, Any]:
1922
- payload = super().to_dict()
1923
- payload["divider"] = self.divider
1924
- payload["spacing"] = self.spacing
1925
- if self.id is not None:
1926
- payload["id"] = self.id
1927
- return payload
1928
-
1929
-
1930
- class Container(Component):
1931
- """Represents a container component."""
1932
-
1933
- def __init__(
1934
- self,
1935
- components: List[Component],
1936
- accent_color: Color | int | str | None = None,
1937
- spoiler: bool = False,
1938
- id: Optional[int] = None,
1939
- ):
1940
- super().__init__(ComponentType.CONTAINER)
1941
- self.components = components
1942
- self.accent_color = Color.parse(accent_color)
1943
- self.spoiler = spoiler
1944
- self.id = id
1945
-
1946
- def to_dict(self) -> Dict[str, Any]:
1947
- payload = super().to_dict()
1948
- payload["components"] = [c.to_dict() for c in self.components]
1949
- if self.accent_color:
1950
- payload["accent_color"] = self.accent_color.value
1951
- if self.spoiler:
1952
- payload["spoiler"] = self.spoiler
1953
- if self.id is not None:
1954
- payload["id"] = self.id
1955
- return payload
1956
-
1957
-
1958
- class WelcomeChannel:
1959
- """Represents a channel shown in the server's welcome screen.
1960
-
1961
- Attributes:
1962
- channel_id (str): The ID of the channel.
1963
- description (str): The description shown for the channel.
1964
- emoji_id (Optional[str]): The ID of the emoji, if custom.
1965
- emoji_name (Optional[str]): The name of the emoji if custom, or the unicode character if standard.
1966
- """
1967
-
1968
- def __init__(self, data: Dict[str, Any]):
1969
- self.channel_id: str = data["channel_id"]
1970
- self.description: str = data["description"]
1971
- self.emoji_id: Optional[str] = data.get("emoji_id")
1972
- self.emoji_name: Optional[str] = data.get("emoji_name")
1973
-
1974
- def __repr__(self) -> str:
1975
- return (
1976
- f"<WelcomeChannel id='{self.channel_id}' description='{self.description}'>"
1977
- )
1978
-
1979
-
1980
- class WelcomeScreen:
1981
- """Represents the welcome screen of a Community guild.
1982
-
1983
- Attributes:
1984
- description (Optional[str]): The server description shown in the welcome screen.
1985
- welcome_channels (List[WelcomeChannel]): The channels shown in the welcome screen.
1986
- """
1987
-
1988
- def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1989
- self._client: "Client" = (
1990
- client_instance # May be useful for fetching channel objects
1991
- )
1992
- self.description: Optional[str] = data.get("description")
1993
- self.welcome_channels: List[WelcomeChannel] = [
1994
- WelcomeChannel(wc_data) for wc_data in data.get("welcome_channels", [])
1995
- ]
1996
-
1997
- def __repr__(self) -> str:
1998
- return f"<WelcomeScreen description='{self.description}' channels={len(self.welcome_channels)}>"
1999
-
2000
-
2001
- class ThreadMember:
2002
- """Represents a member of a thread.
2003
-
2004
- Attributes:
2005
- id (Optional[str]): The ID of the thread. Not always present.
2006
- user_id (Optional[str]): The ID of the user. Not always present.
2007
- join_timestamp (str): When the user joined the thread (ISO8601 timestamp).
2008
- flags (int): User-specific flags for thread settings.
2009
- member (Optional[Member]): The guild member object for this user, if resolved.
2010
- Only available from GUILD_MEMBERS intent and if fetched.
2011
- """
2012
-
2013
- def __init__(
2014
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2015
- ): # client_instance for member resolution
2016
- self._client: Optional["Client"] = client_instance
2017
- self.id: Optional[str] = data.get("id") # Thread ID
2018
- self.user_id: Optional[str] = data.get("user_id")
2019
- self.join_timestamp: str = data["join_timestamp"]
2020
- self.flags: int = data["flags"]
2021
-
2022
- # The 'member' field in ThreadMember payload is a full guild member object.
2023
- # This is present in some contexts like when listing thread members.
2024
- self.member: Optional[Member] = (
2025
- Member(data["member"], client_instance) if data.get("member") else None
2026
- )
2027
-
2028
- # Note: The 'presence' field is not included as it's often unavailable or too dynamic for a simple model.
2029
-
2030
- def __repr__(self) -> str:
2031
- return f"<ThreadMember user_id='{self.user_id}' thread_id='{self.id}'>"
2032
-
2033
-
2034
- class PresenceUpdate:
2035
- """Represents a PRESENCE_UPDATE event."""
2036
-
2037
- def __init__(
2038
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2039
- ):
2040
- self._client = client_instance
2041
- self.user = User(data["user"])
2042
- self.guild_id: Optional[str] = data.get("guild_id")
2043
- self.status: Optional[str] = data.get("status")
2044
- self.activities: List[Dict[str, Any]] = data.get("activities", [])
2045
- self.client_status: Dict[str, Any] = data.get("client_status", {})
2046
-
2047
- def __repr__(self) -> str:
2048
- return f"<PresenceUpdate user_id='{self.user.id}' guild_id='{self.guild_id}' status='{self.status}'>"
2049
-
2050
-
2051
- class TypingStart:
2052
- """Represents a TYPING_START event."""
2053
-
2054
- def __init__(
2055
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2056
- ):
2057
- self._client = client_instance
2058
- self.channel_id: str = data["channel_id"]
2059
- self.guild_id: Optional[str] = data.get("guild_id")
2060
- self.user_id: str = data["user_id"]
2061
- self.timestamp: int = data["timestamp"]
2062
- self.member: Optional[Member] = (
2063
- Member(data["member"], client_instance) if data.get("member") else None
2064
- )
2065
-
2066
- def __repr__(self) -> str:
2067
- return f"<TypingStart channel_id='{self.channel_id}' user_id='{self.user_id}'>"
2068
-
2069
-
2070
- class Reaction:
2071
- """Represents a message reaction event."""
2072
-
2073
- def __init__(
2074
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2075
- ):
2076
- self._client = client_instance
2077
- self.user_id: str = data["user_id"]
2078
- self.channel_id: str = data["channel_id"]
2079
- self.message_id: str = data["message_id"]
2080
- self.guild_id: Optional[str] = data.get("guild_id")
2081
- self.member: Optional[Member] = (
2082
- Member(data["member"], client_instance) if data.get("member") else None
2083
- )
2084
- self.emoji: Dict[str, Any] = data.get("emoji", {})
2085
-
2086
- def __repr__(self) -> str:
2087
- emoji_value = self.emoji.get("name") or self.emoji.get("id")
2088
- return f"<Reaction message_id='{self.message_id}' user_id='{self.user_id}' emoji='{emoji_value}'>"
2089
-
2090
-
2091
- class ScheduledEvent:
2092
- """Represents a guild scheduled event."""
2093
-
2094
- def __init__(
2095
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2096
- ):
2097
- self._client = client_instance
2098
- self.id: str = data["id"]
2099
- self.guild_id: str = data["guild_id"]
2100
- self.channel_id: Optional[str] = data.get("channel_id")
2101
- self.creator_id: Optional[str] = data.get("creator_id")
2102
- self.name: str = data["name"]
2103
- self.description: Optional[str] = data.get("description")
2104
- self.scheduled_start_time: str = data["scheduled_start_time"]
2105
- self.scheduled_end_time: Optional[str] = data.get("scheduled_end_time")
2106
- self.privacy_level: GuildScheduledEventPrivacyLevel = (
2107
- GuildScheduledEventPrivacyLevel(data["privacy_level"])
2108
- )
2109
- self.status: GuildScheduledEventStatus = GuildScheduledEventStatus(
2110
- data["status"]
2111
- )
2112
- self.entity_type: GuildScheduledEventEntityType = GuildScheduledEventEntityType(
2113
- data["entity_type"]
2114
- )
2115
- self.entity_id: Optional[str] = data.get("entity_id")
2116
- self.entity_metadata: Optional[Dict[str, Any]] = data.get("entity_metadata")
2117
- self.creator: Optional[User] = (
2118
- User(data["creator"]) if data.get("creator") else None
2119
- )
2120
- self.user_count: Optional[int] = data.get("user_count")
2121
- self.image: Optional[str] = data.get("image")
2122
-
2123
- def __repr__(self) -> str:
2124
- return f"<ScheduledEvent id='{self.id}' name='{self.name}'>"
2125
-
2126
-
2127
- @dataclass
2128
- class Invite:
2129
- """Represents a Discord invite."""
2130
-
2131
- code: str
2132
- channel_id: Optional[str]
2133
- guild_id: Optional[str]
2134
- inviter_id: Optional[str]
2135
- uses: Optional[int]
2136
- max_uses: Optional[int]
2137
- max_age: Optional[int]
2138
- temporary: Optional[bool]
2139
- created_at: Optional[str]
2140
-
2141
- @classmethod
2142
- def from_dict(cls, data: Dict[str, Any]) -> "Invite":
2143
- channel = data.get("channel")
2144
- guild = data.get("guild")
2145
- inviter = data.get("inviter")
2146
- return cls(
2147
- code=data["code"],
2148
- channel_id=(channel or {}).get("id") if channel else data.get("channel_id"),
2149
- guild_id=(guild or {}).get("id") if guild else data.get("guild_id"),
2150
- inviter_id=(inviter or {}).get("id"),
2151
- uses=data.get("uses"),
2152
- max_uses=data.get("max_uses"),
2153
- max_age=data.get("max_age"),
2154
- temporary=data.get("temporary"),
2155
- created_at=data.get("created_at"),
2156
- )
2157
-
2158
- def __repr__(self) -> str:
2159
- return f"<Invite code='{self.code}' guild_id='{self.guild_id}' channel_id='{self.channel_id}'>"
2160
-
2161
-
2162
- class GuildMemberRemove:
2163
- """Represents a GUILD_MEMBER_REMOVE event."""
2164
-
2165
- def __init__(
2166
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2167
- ):
2168
- self._client = client_instance
2169
- self.guild_id: str = data["guild_id"]
2170
- self.user: User = User(data["user"])
2171
-
2172
- def __repr__(self) -> str:
2173
- return (
2174
- f"<GuildMemberRemove guild_id='{self.guild_id}' user_id='{self.user.id}'>"
2175
- )
2176
-
2177
-
2178
- class GuildBanAdd:
2179
- """Represents a GUILD_BAN_ADD event."""
2180
-
2181
- def __init__(
2182
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2183
- ):
2184
- self._client = client_instance
2185
- self.guild_id: str = data["guild_id"]
2186
- self.user: User = User(data["user"])
2187
-
2188
- def __repr__(self) -> str:
2189
- return f"<GuildBanAdd guild_id='{self.guild_id}' user_id='{self.user.id}'>"
2190
-
2191
-
2192
- class GuildBanRemove:
2193
- """Represents a GUILD_BAN_REMOVE event."""
2194
-
2195
- def __init__(
2196
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2197
- ):
2198
- self._client = client_instance
2199
- self.guild_id: str = data["guild_id"]
2200
- self.user: User = User(data["user"])
2201
-
2202
- def __repr__(self) -> str:
2203
- return f"<GuildBanRemove guild_id='{self.guild_id}' user_id='{self.user.id}'>"
2204
-
2205
-
2206
- class GuildRoleUpdate:
2207
- """Represents a GUILD_ROLE_UPDATE event."""
2208
-
2209
- def __init__(
2210
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2211
- ):
2212
- self._client = client_instance
2213
- self.guild_id: str = data["guild_id"]
2214
- self.role: Role = Role(data["role"])
2215
-
2216
- def __repr__(self) -> str:
2217
- return f"<GuildRoleUpdate guild_id='{self.guild_id}' role_id='{self.role.id}'>"
2218
-
2219
-
2220
- class AuditLogEntry:
2221
- """Represents a single entry in a guild's audit log."""
2222
-
2223
- def __init__(
2224
- self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2225
- ) -> None:
2226
- self._client = client_instance
2227
- self.id: str = data["id"]
2228
- self.user_id: Optional[str] = data.get("user_id")
2229
- self.target_id: Optional[str] = data.get("target_id")
2230
- self.action_type: int = data["action_type"]
2231
- self.reason: Optional[str] = data.get("reason")
2232
- self.changes: List[Dict[str, Any]] = data.get("changes", [])
2233
- self.options: Optional[Dict[str, Any]] = data.get("options")
2234
-
2235
- def __repr__(self) -> str:
2236
- return f"<AuditLogEntry id='{self.id}' action_type={self.action_type} user_id='{self.user_id}'>"
2237
-
2238
-
2239
- def channel_factory(data: Dict[str, Any], client: "Client") -> Channel:
2240
- """Create a channel object from raw API data."""
2241
- channel_type = data.get("type")
2242
-
2243
- if channel_type in (
2244
- ChannelType.GUILD_TEXT.value,
2245
- ChannelType.GUILD_ANNOUNCEMENT.value,
2246
- ):
2247
- return TextChannel(data, client)
2248
- if channel_type == ChannelType.GUILD_VOICE.value:
2249
- return VoiceChannel(data, client)
2250
- if channel_type == ChannelType.GUILD_STAGE_VOICE.value:
2251
- return StageChannel(data, client)
2252
- if channel_type == ChannelType.GUILD_CATEGORY.value:
2253
- return CategoryChannel(data, client)
2254
- if channel_type in (
2255
- ChannelType.ANNOUNCEMENT_THREAD.value,
2256
- ChannelType.PUBLIC_THREAD.value,
2257
- ChannelType.PRIVATE_THREAD.value,
2258
- ):
2259
- return Thread(data, client)
2260
- if channel_type in (ChannelType.DM.value, ChannelType.GROUP_DM.value):
2261
- return DMChannel(data, client)
2262
-
2263
- return Channel(data, client)
1
+ """
2
+ Data models for Discord objects.
3
+ """
4
+
5
+ import asyncio
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass
9
+ from typing import Any, AsyncIterator, Dict, List, Optional, TYPE_CHECKING, Union, cast
10
+
11
+ from .cache import ChannelCache, MemberCache
12
+ from .caching import MemberCacheFlags
13
+
14
+ import aiohttp # pylint: disable=import-error
15
+ from .color import Color
16
+ from .errors import DisagreementException, HTTPException
17
+ from .enums import ( # These enums will need to be defined in disagreement/enums.py
18
+ VerificationLevel,
19
+ MessageNotificationLevel,
20
+ ExplicitContentFilterLevel,
21
+ MFALevel,
22
+ GuildNSFWLevel,
23
+ PremiumTier,
24
+ GuildFeature,
25
+ ChannelType,
26
+ AutoArchiveDuration,
27
+ ComponentType,
28
+ ButtonStyle, # Added for Button
29
+ GuildScheduledEventPrivacyLevel,
30
+ GuildScheduledEventStatus,
31
+ GuildScheduledEventEntityType,
32
+ # SelectMenuType will be part of ComponentType or a new enum if needed
33
+ )
34
+ from .permissions import Permissions
35
+
36
+
37
+ if TYPE_CHECKING:
38
+ from .client import Client # For type hinting to avoid circular imports
39
+ from .enums import OverwriteType # For PermissionOverwrite model
40
+ from .ui.view import View
41
+ from .interactions import Snowflake
42
+ from .typing import Typing
43
+
44
+ # Forward reference Message if it were used in type hints before its definition
45
+ # from .models import Message # Not needed as Message is defined before its use in TextChannel.send etc.
46
+ from .components import component_factory
47
+
48
+
49
+ class User:
50
+ """Represents a Discord User.
51
+
52
+ Attributes:
53
+ id (str): The user's unique ID.
54
+ username (str): The user's username.
55
+ discriminator (str): The user's 4-digit discord-tag.
56
+ bot (bool): Whether the user belongs to an OAuth2 application. Defaults to False.
57
+ avatar (Optional[str]): The user's avatar hash, if any.
58
+ """
59
+
60
+ def __init__(self, data: dict):
61
+ self.id: str = data["id"]
62
+ self.username: str = data["username"]
63
+ self.discriminator: str = data["discriminator"]
64
+ self.bot: bool = data.get("bot", False)
65
+ self.avatar: Optional[str] = data.get("avatar")
66
+
67
+ @property
68
+ def mention(self) -> str:
69
+ """str: Returns a string that allows you to mention the user."""
70
+ return f"<@{self.id}>"
71
+
72
+ def __repr__(self) -> str:
73
+ return f"<User id='{self.id}' username='{self.username}' discriminator='{self.discriminator}'>"
74
+
75
+
76
+ class Message:
77
+ """Represents a message sent in a channel on Discord.
78
+
79
+ Attributes:
80
+ id (str): The message's unique ID.
81
+ channel_id (str): The ID of the channel the message was sent in.
82
+ guild_id (Optional[str]): The ID of the guild the message was sent in, if applicable.
83
+ author (User): The user who sent the message.
84
+ content (str): The actual content of the message.
85
+ timestamp (str): When this message was sent (ISO8601 timestamp).
86
+ components (Optional[List[ActionRow]]): Structured components attached
87
+ to the message if present.
88
+ attachments (List[Attachment]): Attachments included with the message.
89
+ """
90
+
91
+ def __init__(self, data: dict, client_instance: "Client"):
92
+ self._client: "Client" = (
93
+ client_instance # Store reference to client for methods like reply
94
+ )
95
+
96
+ self.id: str = data["id"]
97
+ self.channel_id: str = data["channel_id"]
98
+ self.guild_id: Optional[str] = data.get("guild_id")
99
+ self.author: User = User(data["author"])
100
+ self.content: str = data["content"]
101
+ self.timestamp: str = data["timestamp"]
102
+ if data.get("components"):
103
+ self.components: Optional[List[ActionRow]] = [
104
+ ActionRow.from_dict(c, client_instance)
105
+ for c in data.get("components", [])
106
+ ]
107
+ else:
108
+ self.components = None
109
+ self.attachments: List[Attachment] = [
110
+ Attachment(a) for a in data.get("attachments", [])
111
+ ]
112
+ self.pinned: bool = data.get("pinned", False)
113
+ # Add other fields as needed, e.g., attachments, embeds, reactions, etc.
114
+ # self.mentions: List[User] = [User(u) for u in data.get("mentions", [])]
115
+ # self.mention_roles: List[str] = data.get("mention_roles", [])
116
+ # self.mention_everyone: bool = data.get("mention_everyone", False)
117
+
118
+ @property
119
+ def clean_content(self) -> str:
120
+ """Returns message content without user, role, or channel mentions."""
121
+
122
+ pattern = re.compile(r"<@!?\d+>|<#\d+>|<@&\d+>")
123
+ cleaned = pattern.sub("", self.content)
124
+ return " ".join(cleaned.split())
125
+
126
+ async def pin(self) -> None:
127
+ """|coro|
128
+
129
+ Pins this message to its channel.
130
+
131
+ Raises
132
+ ------
133
+ HTTPException
134
+ Pinning the message failed.
135
+ """
136
+ await self._client._http.pin_message(self.channel_id, self.id)
137
+ self.pinned = True
138
+
139
+ async def unpin(self) -> None:
140
+ """|coro|
141
+
142
+ Unpins this message from its channel.
143
+
144
+ Raises
145
+ ------
146
+ HTTPException
147
+ Unpinning the message failed.
148
+ """
149
+ await self._client._http.unpin_message(self.channel_id, self.id)
150
+ self.pinned = False
151
+
152
+ async def reply(
153
+ self,
154
+ content: Optional[str] = None,
155
+ *, # Make additional params keyword-only
156
+ tts: bool = False,
157
+ embed: Optional["Embed"] = None,
158
+ embeds: Optional[List["Embed"]] = None,
159
+ components: Optional[List["ActionRow"]] = None,
160
+ allowed_mentions: Optional[Dict[str, Any]] = None,
161
+ mention_author: Optional[bool] = None,
162
+ flags: Optional[int] = None,
163
+ view: Optional["View"] = None,
164
+ ) -> "Message":
165
+ """|coro|
166
+
167
+ Sends a reply to the message.
168
+ This is a shorthand for `Client.send_message` in the message's channel.
169
+
170
+ Parameters:
171
+ content (Optional[str]): The content of the message.
172
+ tts (bool): Whether the message should be sent with text-to-speech.
173
+ embed (Optional[Embed]): A single embed to send. Cannot be used with `embeds`.
174
+ embeds (Optional[List[Embed]]): A list of embeds to send.
175
+ components (Optional[List[ActionRow]]): A list of ActionRow components.
176
+ allowed_mentions (Optional[Dict[str, Any]]): Allowed mentions for the message.
177
+ mention_author (Optional[bool]): Whether to mention the author in the reply. If ``None`` the
178
+ client's :attr:`mention_replies` setting is used.
179
+ flags (Optional[int]): Message flags.
180
+ view (Optional[View]): A view to send with the message.
181
+
182
+ Returns:
183
+ Message: The message that was sent.
184
+
185
+ Raises:
186
+ HTTPException: Sending the message failed.
187
+ ValueError: If both `embed` and `embeds` are provided.
188
+ """
189
+ # Determine allowed mentions for the reply
190
+ if mention_author is None:
191
+ mention_author = getattr(self._client, "mention_replies", False)
192
+
193
+ if allowed_mentions is None:
194
+ allowed_mentions = {"replied_user": mention_author}
195
+ else:
196
+ allowed_mentions = dict(allowed_mentions)
197
+ allowed_mentions.setdefault("replied_user", mention_author)
198
+
199
+ # Client.send_message is already updated to handle these parameters
200
+ return await self._client.send_message(
201
+ channel_id=self.channel_id,
202
+ content=content,
203
+ tts=tts,
204
+ embed=embed,
205
+ embeds=embeds,
206
+ components=components,
207
+ allowed_mentions=allowed_mentions,
208
+ message_reference={
209
+ "message_id": self.id,
210
+ "channel_id": self.channel_id,
211
+ "guild_id": self.guild_id,
212
+ },
213
+ flags=flags,
214
+ view=view,
215
+ )
216
+
217
+ async def edit(
218
+ self,
219
+ *,
220
+ content: Optional[str] = None,
221
+ embed: Optional["Embed"] = None,
222
+ embeds: Optional[List["Embed"]] = None,
223
+ components: Optional[List["ActionRow"]] = None,
224
+ allowed_mentions: Optional[Dict[str, Any]] = None,
225
+ flags: Optional[int] = None,
226
+ view: Optional["View"] = None,
227
+ ) -> "Message":
228
+ """|coro|
229
+
230
+ Edits this message.
231
+
232
+ Parameters are the same as :meth:`Client.edit_message`.
233
+ """
234
+
235
+ return await self._client.edit_message(
236
+ channel_id=self.channel_id,
237
+ message_id=self.id,
238
+ content=content,
239
+ embed=embed,
240
+ embeds=embeds,
241
+ components=components,
242
+ allowed_mentions=allowed_mentions,
243
+ flags=flags,
244
+ view=view,
245
+ )
246
+
247
+ async def add_reaction(self, emoji: str) -> None:
248
+ """|coro| Add a reaction to this message."""
249
+
250
+ await self._client.add_reaction(self.channel_id, self.id, emoji)
251
+
252
+ async def remove_reaction(self, emoji: str, member: Optional[User] = None) -> None:
253
+ """|coro|
254
+ Removes a reaction from this message.
255
+ If no ``member`` is provided, removes the bot's own reaction.
256
+ """
257
+ if member:
258
+ await self._client._http.delete_user_reaction(
259
+ self.channel_id, self.id, emoji, member.id
260
+ )
261
+ else:
262
+ await self._client.remove_reaction(self.channel_id, self.id, emoji)
263
+
264
+ async def clear_reactions(self) -> None:
265
+ """|coro| Remove all reactions from this message."""
266
+
267
+ await self._client.clear_reactions(self.channel_id, self.id)
268
+
269
+ async def delete(self, delay: Optional[float] = None) -> None:
270
+ """|coro|
271
+
272
+ Deletes this message.
273
+
274
+ Parameters
275
+ ----------
276
+ delay:
277
+ If provided, wait this many seconds before deleting.
278
+ """
279
+
280
+ if delay is not None:
281
+ await asyncio.sleep(delay)
282
+
283
+ await self._client._http.delete_message(self.channel_id, self.id)
284
+
285
+ def __repr__(self) -> str:
286
+ return f"<Message id='{self.id}' channel_id='{self.channel_id}' author='{self.author!r}'>"
287
+
288
+ async def create_thread(
289
+ self,
290
+ name: str,
291
+ *,
292
+ auto_archive_duration: Optional[AutoArchiveDuration] = None,
293
+ rate_limit_per_user: Optional[int] = None,
294
+ reason: Optional[str] = None,
295
+ ) -> "Thread":
296
+ """|coro|
297
+
298
+ Creates a new thread from this message.
299
+
300
+ Parameters
301
+ ----------
302
+ name: str
303
+ The name of the thread.
304
+ auto_archive_duration: Optional[AutoArchiveDuration]
305
+ How long before the thread is automatically archived after recent activity.
306
+ See :class:`AutoArchiveDuration` for allowed values.
307
+ rate_limit_per_user: Optional[int]
308
+ The number of seconds a user has to wait before sending another message.
309
+ reason: Optional[str]
310
+ The reason for creating the thread.
311
+
312
+ Returns
313
+ -------
314
+ Thread
315
+ The created thread.
316
+ """
317
+ payload: Dict[str, Any] = {"name": name}
318
+ if auto_archive_duration is not None:
319
+ payload["auto_archive_duration"] = int(auto_archive_duration)
320
+ if rate_limit_per_user is not None:
321
+ payload["rate_limit_per_user"] = rate_limit_per_user
322
+
323
+ data = await self._client._http.start_thread_from_message(
324
+ self.channel_id, self.id, payload
325
+ )
326
+ return cast("Thread", self._client.parse_channel(data))
327
+
328
+
329
+ class PartialMessage:
330
+ """Represents a partial message, identified by its ID and channel.
331
+
332
+ This model is used to perform actions on a message without having the
333
+ full message object in the cache.
334
+
335
+ Attributes:
336
+ id (str): The message's unique ID.
337
+ channel (TextChannel): The text channel this message belongs to.
338
+ """
339
+
340
+ def __init__(self, *, id: str, channel: "TextChannel"):
341
+ self.id = id
342
+ self.channel = channel
343
+ self._client = channel._client
344
+
345
+ async def fetch(self) -> "Message":
346
+ """|coro|
347
+
348
+ Fetches the full message data from Discord.
349
+
350
+ Returns
351
+ -------
352
+ Message
353
+ The complete message object.
354
+ """
355
+ data = await self._client._http.get_message(self.channel.id, self.id)
356
+ return self._client.parse_message(data)
357
+
358
+ async def delete(self, *, delay: Optional[float] = None) -> None:
359
+ """|coro|
360
+
361
+ Deletes this message.
362
+
363
+ Parameters
364
+ ----------
365
+ delay: Optional[float]
366
+ If provided, wait this many seconds before deleting.
367
+ """
368
+ if delay is not None:
369
+ await asyncio.sleep(delay)
370
+ await self._client._http.delete_message(self.channel.id, self.id)
371
+
372
+ async def pin(self) -> None:
373
+ """|coro|
374
+
375
+ Pins this message to its channel.
376
+ """
377
+ await self._client._http.pin_message(self.channel.id, self.id)
378
+
379
+ async def unpin(self) -> None:
380
+ """|coro|
381
+
382
+ Unpins this message from its channel.
383
+ """
384
+ await self._client._http.unpin_message(self.channel.id, self.id)
385
+
386
+ async def add_reaction(self, emoji: str) -> None:
387
+ """|coro|
388
+
389
+ Adds a reaction to this message.
390
+ """
391
+ await self._client._http.create_reaction(self.channel.id, self.id, emoji)
392
+
393
+ async def remove_reaction(self, emoji: str, member: Optional[User] = None) -> None:
394
+ """|coro|
395
+
396
+ Removes a reaction from this message.
397
+
398
+ If no ``member`` is provided, removes the bot's own reaction.
399
+ """
400
+ if member:
401
+ await self._client._http.delete_user_reaction(
402
+ self.channel.id, self.id, emoji, member.id
403
+ )
404
+ else:
405
+ await self._client._http.delete_reaction(self.channel.id, self.id, emoji)
406
+
407
+
408
+ class EmbedFooter:
409
+ """Represents an embed footer."""
410
+
411
+ def __init__(self, data: Dict[str, Any]):
412
+ self.text: str = data["text"]
413
+ self.icon_url: Optional[str] = data.get("icon_url")
414
+ self.proxy_icon_url: Optional[str] = data.get("proxy_icon_url")
415
+
416
+ def to_dict(self) -> Dict[str, Any]:
417
+ payload = {"text": self.text}
418
+ if self.icon_url:
419
+ payload["icon_url"] = self.icon_url
420
+ if self.proxy_icon_url:
421
+ payload["proxy_icon_url"] = self.proxy_icon_url
422
+ return payload
423
+
424
+
425
+ class EmbedImage:
426
+ """Represents an embed image."""
427
+
428
+ def __init__(self, data: Dict[str, Any]):
429
+ self.url: str = data["url"]
430
+ self.proxy_url: Optional[str] = data.get("proxy_url")
431
+ self.height: Optional[int] = data.get("height")
432
+ self.width: Optional[int] = data.get("width")
433
+
434
+ def to_dict(self) -> Dict[str, Any]:
435
+ payload: Dict[str, Any] = {"url": self.url}
436
+ if self.proxy_url:
437
+ payload["proxy_url"] = self.proxy_url
438
+ if self.height:
439
+ payload["height"] = self.height
440
+ if self.width:
441
+ payload["width"] = self.width
442
+ return payload
443
+
444
+ def __repr__(self) -> str:
445
+ return f"<EmbedImage url='{self.url}'>"
446
+
447
+
448
+ class EmbedThumbnail(EmbedImage): # Similar structure to EmbedImage
449
+ """Represents an embed thumbnail."""
450
+
451
+ pass
452
+
453
+
454
+ class EmbedAuthor:
455
+ """Represents an embed author."""
456
+
457
+ def __init__(self, data: Dict[str, Any]):
458
+ self.name: str = data["name"]
459
+ self.url: Optional[str] = data.get("url")
460
+ self.icon_url: Optional[str] = data.get("icon_url")
461
+ self.proxy_icon_url: Optional[str] = data.get("proxy_icon_url")
462
+
463
+ def to_dict(self) -> Dict[str, Any]:
464
+ payload = {"name": self.name}
465
+ if self.url:
466
+ payload["url"] = self.url
467
+ if self.icon_url:
468
+ payload["icon_url"] = self.icon_url
469
+ if self.proxy_icon_url:
470
+ payload["proxy_icon_url"] = self.proxy_icon_url
471
+ return payload
472
+
473
+
474
+ class EmbedField:
475
+ """Represents an embed field."""
476
+
477
+ def __init__(self, data: Dict[str, Any]):
478
+ self.name: str = data["name"]
479
+ self.value: str = data["value"]
480
+ self.inline: bool = data.get("inline", False)
481
+
482
+ def to_dict(self) -> Dict[str, Any]:
483
+ return {"name": self.name, "value": self.value, "inline": self.inline}
484
+
485
+
486
+ class Embed:
487
+ """Represents a Discord embed.
488
+
489
+ Attributes can be set directly or via methods like `set_author`, `add_field`.
490
+ """
491
+
492
+ def __init__(self, data: Optional[Dict[str, Any]] = None):
493
+ data = data or {}
494
+ self.title: Optional[str] = data.get("title")
495
+ self.type: str = data.get("type", "rich") # Default to "rich" for sending
496
+ self.description: Optional[str] = data.get("description")
497
+ self.url: Optional[str] = data.get("url")
498
+ self.timestamp: Optional[str] = data.get("timestamp") # ISO8601 timestamp
499
+ self.color = Color.parse(data.get("color"))
500
+
501
+ self.footer: Optional[EmbedFooter] = (
502
+ EmbedFooter(data["footer"]) if data.get("footer") else None
503
+ )
504
+ self.image: Optional[EmbedImage] = (
505
+ EmbedImage(data["image"]) if data.get("image") else None
506
+ )
507
+ self.thumbnail: Optional[EmbedThumbnail] = (
508
+ EmbedThumbnail(data["thumbnail"]) if data.get("thumbnail") else None
509
+ )
510
+ # Video and Provider are less common for bot-sent embeds, can be added if needed.
511
+ self.author: Optional[EmbedAuthor] = (
512
+ EmbedAuthor(data["author"]) if data.get("author") else None
513
+ )
514
+ self.fields: List[EmbedField] = (
515
+ [EmbedField(f) for f in data["fields"]] if data.get("fields") else []
516
+ )
517
+
518
+ def to_dict(self) -> Dict[str, Any]:
519
+ payload: Dict[str, Any] = {"type": self.type}
520
+ if self.title:
521
+ payload["title"] = self.title
522
+ if self.description:
523
+ payload["description"] = self.description
524
+ if self.url:
525
+ payload["url"] = self.url
526
+ if self.timestamp:
527
+ payload["timestamp"] = self.timestamp
528
+ if self.color is not None:
529
+ payload["color"] = self.color.value
530
+ if self.footer:
531
+ payload["footer"] = self.footer.to_dict()
532
+ if self.image:
533
+ payload["image"] = self.image.to_dict()
534
+ if self.thumbnail:
535
+ payload["thumbnail"] = self.thumbnail.to_dict()
536
+ if self.author:
537
+ payload["author"] = self.author.to_dict()
538
+ if self.fields:
539
+ payload["fields"] = [f.to_dict() for f in self.fields]
540
+ return payload
541
+
542
+ # Convenience methods mirroring ``discord.py``'s ``Embed`` API
543
+
544
+ def set_author(
545
+ self, *, name: str, url: Optional[str] = None, icon_url: Optional[str] = None
546
+ ) -> "Embed":
547
+ """Set the embed author and return ``self`` for chaining."""
548
+
549
+ data: Dict[str, Any] = {"name": name}
550
+ if url:
551
+ data["url"] = url
552
+ if icon_url:
553
+ data["icon_url"] = icon_url
554
+ self.author = EmbedAuthor(data)
555
+ return self
556
+
557
+ def add_field(self, *, name: str, value: str, inline: bool = False) -> "Embed":
558
+ """Add a field to the embed."""
559
+
560
+ field = EmbedField({"name": name, "value": value, "inline": inline})
561
+ self.fields.append(field)
562
+ return self
563
+
564
+ def set_footer(self, *, text: str, icon_url: Optional[str] = None) -> "Embed":
565
+ """Set the embed footer."""
566
+
567
+ data: Dict[str, Any] = {"text": text}
568
+ if icon_url:
569
+ data["icon_url"] = icon_url
570
+ self.footer = EmbedFooter(data)
571
+ return self
572
+
573
+ def set_image(self, url: str) -> "Embed":
574
+ """Set the embed image."""
575
+
576
+ self.image = EmbedImage({"url": url})
577
+ return self
578
+
579
+
580
+ class Attachment:
581
+ """Represents a message attachment."""
582
+
583
+ def __init__(self, data: Dict[str, Any]):
584
+ self.id: str = data["id"]
585
+ self.filename: str = data["filename"]
586
+ self.description: Optional[str] = data.get("description")
587
+ self.content_type: Optional[str] = data.get("content_type")
588
+ self.size: Optional[int] = data.get("size")
589
+ self.url: Optional[str] = data.get("url")
590
+ self.proxy_url: Optional[str] = data.get("proxy_url")
591
+ self.height: Optional[int] = data.get("height") # If image
592
+ self.width: Optional[int] = data.get("width") # If image
593
+ self.ephemeral: bool = data.get("ephemeral", False)
594
+
595
+ def __repr__(self) -> str:
596
+ return f"<Attachment id='{self.id}' filename='{self.filename}'>"
597
+
598
+ def to_dict(self) -> Dict[str, Any]:
599
+ payload: Dict[str, Any] = {"id": self.id, "filename": self.filename}
600
+ if self.description is not None:
601
+ payload["description"] = self.description
602
+ if self.content_type is not None:
603
+ payload["content_type"] = self.content_type
604
+ if self.size is not None:
605
+ payload["size"] = self.size
606
+ if self.url is not None:
607
+ payload["url"] = self.url
608
+ if self.proxy_url is not None:
609
+ payload["proxy_url"] = self.proxy_url
610
+ if self.height is not None:
611
+ payload["height"] = self.height
612
+ if self.width is not None:
613
+ payload["width"] = self.width
614
+ if self.ephemeral:
615
+ payload["ephemeral"] = self.ephemeral
616
+ return payload
617
+
618
+
619
+ class File:
620
+ """Represents a file to be uploaded."""
621
+
622
+ def __init__(self, filename: str, data: bytes):
623
+ self.filename = filename
624
+ self.data = data
625
+
626
+
627
+ class AllowedMentions:
628
+ """Represents allowed mentions for a message or interaction response."""
629
+
630
+ def __init__(self, data: Dict[str, Any]):
631
+ self.parse: List[str] = data.get("parse", [])
632
+ self.roles: List[str] = data.get("roles", [])
633
+ self.users: List[str] = data.get("users", [])
634
+ self.replied_user: bool = data.get("replied_user", False)
635
+
636
+ def to_dict(self) -> Dict[str, Any]:
637
+ payload: Dict[str, Any] = {"parse": self.parse}
638
+ if self.roles:
639
+ payload["roles"] = self.roles
640
+ if self.users:
641
+ payload["users"] = self.users
642
+ if self.replied_user:
643
+ payload["replied_user"] = self.replied_user
644
+ return payload
645
+
646
+
647
+ class RoleTags:
648
+ """Represents tags for a role."""
649
+
650
+ def __init__(self, data: Dict[str, Any]):
651
+ self.bot_id: Optional[str] = data.get("bot_id")
652
+ self.integration_id: Optional[str] = data.get("integration_id")
653
+ self.premium_subscriber: Optional[bool] = (
654
+ data.get("premium_subscriber") is None
655
+ ) # presence of null value means true
656
+
657
+ def to_dict(self) -> Dict[str, Any]:
658
+ payload = {}
659
+ if self.bot_id:
660
+ payload["bot_id"] = self.bot_id
661
+ if self.integration_id:
662
+ payload["integration_id"] = self.integration_id
663
+ if self.premium_subscriber:
664
+ payload["premium_subscriber"] = None # Explicitly null
665
+ return payload
666
+
667
+
668
+ class Role:
669
+ """Represents a Discord Role."""
670
+
671
+ def __init__(self, data: Dict[str, Any]):
672
+ self.id: str = data["id"]
673
+ self.name: str = data["name"]
674
+ self.color: int = data["color"]
675
+ self.hoist: bool = data["hoist"]
676
+ self.icon: Optional[str] = data.get("icon")
677
+ self.unicode_emoji: Optional[str] = data.get("unicode_emoji")
678
+ self.position: int = data["position"]
679
+ self.permissions: str = data["permissions"] # String of bitwise permissions
680
+ self.managed: bool = data["managed"]
681
+ self.mentionable: bool = data["mentionable"]
682
+ self.tags: Optional[RoleTags] = (
683
+ RoleTags(data["tags"]) if data.get("tags") else None
684
+ )
685
+
686
+ @property
687
+ def mention(self) -> str:
688
+ """str: Returns a string that allows you to mention the role."""
689
+ return f"<@&{self.id}>"
690
+
691
+ def __repr__(self) -> str:
692
+ return f"<Role id='{self.id}' name='{self.name}'>"
693
+
694
+
695
+ class Member(User): # Member inherits from User
696
+ """Represents a Guild Member.
697
+ This class combines User attributes with guild-specific Member attributes.
698
+ """
699
+
700
+ def __init__(
701
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
702
+ ):
703
+ self._client: Optional["Client"] = client_instance
704
+ self.guild_id: Optional[str] = None
705
+ self.status: Optional[str] = None
706
+ self.voice_state: Optional[Dict[str, Any]] = None
707
+ # User part is nested under 'user' key in member data from gateway/API
708
+ user_data = data.get("user", {})
709
+ # If 'id' is not in user_data but is top-level (e.g. from interaction resolved member without user object)
710
+ if "id" not in user_data and "id" in data:
711
+ # This case is less common for full member objects but can happen.
712
+ # We'd need to construct a partial user from top-level member fields if 'user' is missing.
713
+ # For now, assume 'user' object is present for full Member hydration.
714
+ # If 'user' is missing, the User part might be incomplete.
715
+ pass
716
+
717
+ super().__init__(
718
+ user_data if user_data else data
719
+ ) # Pass user_data or data if user_data is empty
720
+
721
+ self.nick: Optional[str] = data.get("nick")
722
+ self.avatar: Optional[str] = data.get("avatar") # Guild-specific avatar hash
723
+ self.roles: List[str] = data.get("roles", []) # List of role IDs
724
+ self.joined_at: str = data["joined_at"] # ISO8601 timestamp
725
+ self.premium_since: Optional[str] = data.get(
726
+ "premium_since"
727
+ ) # ISO8601 timestamp
728
+ self.deaf: bool = data.get("deaf", False)
729
+ self.mute: bool = data.get("mute", False)
730
+ self.pending: bool = data.get("pending", False)
731
+ self.permissions: Optional[str] = data.get(
732
+ "permissions"
733
+ ) # Permissions in the channel, if applicable
734
+ self.communication_disabled_until: Optional[str] = data.get(
735
+ "communication_disabled_until"
736
+ ) # ISO8601 timestamp
737
+
738
+ # If 'user' object was present, ensure User attributes are from there
739
+ if user_data:
740
+ self.id = user_data.get("id", self.id) # Prefer user.id if available
741
+ self.username = user_data.get("username", self.username)
742
+ self.discriminator = user_data.get("discriminator", self.discriminator)
743
+ self.bot = user_data.get("bot", self.bot)
744
+ # User's global avatar is User.avatar, Member.avatar is guild-specific
745
+ # super() already set self.avatar from user_data if present.
746
+ # The self.avatar = data.get("avatar") line above overwrites it with guild avatar. This is correct.
747
+
748
+ def __repr__(self) -> str:
749
+ return f"<Member id='{self.id}' username='{self.username}' nick='{self.nick}'>"
750
+
751
+ @property
752
+ def display_name(self) -> str:
753
+ """Return the nickname if set, otherwise the username."""
754
+
755
+ return self.nick or self.username
756
+
757
+ async def kick(self, *, reason: Optional[str] = None) -> None:
758
+ if not self.guild_id or not self._client:
759
+ raise DisagreementException("Member.kick requires guild_id and client")
760
+ await self._client._http.kick_member(self.guild_id, self.id, reason=reason)
761
+
762
+ async def ban(
763
+ self,
764
+ *,
765
+ delete_message_seconds: int = 0,
766
+ reason: Optional[str] = None,
767
+ ) -> None:
768
+ if not self.guild_id or not self._client:
769
+ raise DisagreementException("Member.ban requires guild_id and client")
770
+ await self._client._http.ban_member(
771
+ self.guild_id,
772
+ self.id,
773
+ delete_message_seconds=delete_message_seconds,
774
+ reason=reason,
775
+ )
776
+
777
+ async def timeout(
778
+ self, until: Optional[str], *, reason: Optional[str] = None
779
+ ) -> None:
780
+ if not self.guild_id or not self._client:
781
+ raise DisagreementException("Member.timeout requires guild_id and client")
782
+ await self._client._http.timeout_member(
783
+ self.guild_id,
784
+ self.id,
785
+ until=until,
786
+ reason=reason,
787
+ )
788
+
789
+ @property
790
+ def top_role(self) -> Optional["Role"]:
791
+ """Return the member's highest role from the guild cache."""
792
+
793
+ if not self.guild_id or not self._client:
794
+ return None
795
+
796
+ guild = self._client.get_guild(self.guild_id)
797
+ if not guild:
798
+ return None
799
+
800
+ if not guild.roles and hasattr(self._client, "fetch_roles"):
801
+ try:
802
+ self._client.loop.run_until_complete(
803
+ self._client.fetch_roles(self.guild_id)
804
+ )
805
+ except RuntimeError:
806
+ future = asyncio.run_coroutine_threadsafe(
807
+ self._client.fetch_roles(self.guild_id), self._client.loop
808
+ )
809
+ future.result()
810
+
811
+ role_objects = [r for r in guild.roles if r.id in self.roles]
812
+ if not role_objects:
813
+ return None
814
+
815
+ return max(role_objects, key=lambda r: r.position)
816
+
817
+
818
+ class PartialEmoji:
819
+ """Represents a partial emoji, often used in components or reactions.
820
+
821
+ This typically means only id, name, and animated are known.
822
+ For unicode emojis, id will be None and name will be the unicode character.
823
+ """
824
+
825
+ def __init__(self, data: Dict[str, Any]):
826
+ self.id: Optional[str] = data.get("id")
827
+ self.name: Optional[str] = data.get(
828
+ "name"
829
+ ) # Can be None for unknown custom emoji, or unicode char
830
+ self.animated: bool = data.get("animated", False)
831
+
832
+ def to_dict(self) -> Dict[str, Any]:
833
+ payload: Dict[str, Any] = {}
834
+ if self.id:
835
+ payload["id"] = self.id
836
+ if self.name:
837
+ payload["name"] = self.name
838
+ if self.animated: # Only include if true, as per some Discord patterns
839
+ payload["animated"] = self.animated
840
+ return payload
841
+
842
+ def __str__(self) -> str:
843
+ if self.id:
844
+ return f"<{'a' if self.animated else ''}:{self.name}:{self.id}>"
845
+ return self.name or "" # For unicode emoji
846
+
847
+ def __repr__(self) -> str:
848
+ return (
849
+ f"<PartialEmoji id='{self.id}' name='{self.name}' animated={self.animated}>"
850
+ )
851
+
852
+
853
+ def to_partial_emoji(
854
+ value: Union[str, "PartialEmoji", None],
855
+ ) -> Optional["PartialEmoji"]:
856
+ """Convert a string or PartialEmoji to a PartialEmoji instance.
857
+
858
+ Args:
859
+ value: Either a unicode emoji string, a :class:`PartialEmoji`, or ``None``.
860
+
861
+ Returns:
862
+ A :class:`PartialEmoji` or ``None`` if ``value`` was ``None``.
863
+
864
+ Raises:
865
+ TypeError: If ``value`` is not ``str`` or :class:`PartialEmoji`.
866
+ """
867
+
868
+ if value is None or isinstance(value, PartialEmoji):
869
+ return value
870
+ if isinstance(value, str):
871
+ return PartialEmoji({"name": value, "id": None})
872
+ raise TypeError("emoji must be a str or PartialEmoji")
873
+
874
+
875
+ class Emoji(PartialEmoji):
876
+ """Represents a custom guild emoji.
877
+
878
+ Inherits id, name, animated from PartialEmoji.
879
+ """
880
+
881
+ def __init__(
882
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
883
+ ):
884
+ super().__init__(data)
885
+ self._client: Optional["Client"] = (
886
+ client_instance # For potential future methods
887
+ )
888
+
889
+ # Roles this emoji is whitelisted to
890
+ self.roles: List[str] = data.get("roles", []) # List of role IDs
891
+
892
+ # User object for the user that created this emoji (optional, only for GUILD_EMOJIS_AND_STICKERS intent)
893
+ self.user: Optional[User] = User(data["user"]) if data.get("user") else None
894
+
895
+ self.require_colons: bool = data.get("require_colons", False)
896
+ self.managed: bool = data.get(
897
+ "managed", False
898
+ ) # If this emoji is managed by an integration
899
+ self.available: bool = data.get(
900
+ "available", True
901
+ ) # Whether this emoji can be used
902
+
903
+ def __repr__(self) -> str:
904
+ return f"<Emoji id='{self.id}' name='{self.name}' animated={self.animated} available={self.available}>"
905
+
906
+
907
+ class StickerItem:
908
+ """Represents a sticker item, a basic representation of a sticker.
909
+
910
+ Used in sticker packs and sometimes in message data.
911
+ """
912
+
913
+ def __init__(self, data: Dict[str, Any]):
914
+ self.id: str = data["id"]
915
+ self.name: str = data["name"]
916
+ self.format_type: int = data["format_type"] # StickerFormatType enum
917
+
918
+ def __repr__(self) -> str:
919
+ return f"<StickerItem id='{self.id}' name='{self.name}'>"
920
+
921
+
922
+ class Sticker(StickerItem):
923
+ """Represents a Discord sticker.
924
+
925
+ Inherits id, name, format_type from StickerItem.
926
+ """
927
+
928
+ def __init__(
929
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
930
+ ):
931
+ super().__init__(data)
932
+ self._client: Optional["Client"] = client_instance
933
+
934
+ self.pack_id: Optional[str] = data.get(
935
+ "pack_id"
936
+ ) # For standard stickers, ID of the pack
937
+ self.description: Optional[str] = data.get("description")
938
+ self.tags: str = data.get(
939
+ "tags", ""
940
+ ) # Comma-separated list of tags for guild stickers
941
+ # type is StickerType enum (STANDARD or GUILD)
942
+ # For guild stickers, this is 2. For standard stickers, this is 1.
943
+ self.type: int = data["type"]
944
+ self.available: bool = data.get(
945
+ "available", True
946
+ ) # Whether this sticker can be used
947
+ self.guild_id: Optional[str] = data.get(
948
+ "guild_id"
949
+ ) # ID of the guild that owns this sticker
950
+
951
+ # User object of the user that uploaded the guild sticker
952
+ self.user: Optional[User] = User(data["user"]) if data.get("user") else None
953
+
954
+ self.sort_value: Optional[int] = data.get(
955
+ "sort_value"
956
+ ) # The standard sticker's sort order within its pack
957
+
958
+ def __repr__(self) -> str:
959
+ return f"<Sticker id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
960
+
961
+
962
+ class StickerPack:
963
+ """Represents a pack of standard stickers."""
964
+
965
+ def __init__(
966
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
967
+ ):
968
+ self._client: Optional["Client"] = client_instance
969
+ self.id: str = data["id"]
970
+ self.stickers: List[Sticker] = [
971
+ Sticker(s_data, client_instance) for s_data in data.get("stickers", [])
972
+ ]
973
+ self.name: str = data["name"]
974
+ self.sku_id: str = data["sku_id"]
975
+ self.cover_sticker_id: Optional[str] = data.get("cover_sticker_id")
976
+ self.description: str = data["description"]
977
+ self.banner_asset_id: Optional[str] = data.get(
978
+ "banner_asset_id"
979
+ ) # ID of the pack's banner image
980
+
981
+ def __repr__(self) -> str:
982
+ return f"<StickerPack id='{self.id}' name='{self.name}' stickers={len(self.stickers)}>"
983
+
984
+
985
+ class PermissionOverwrite:
986
+ """Represents a permission overwrite for a role or member in a channel."""
987
+
988
+ def __init__(self, data: Dict[str, Any]):
989
+ self.id: str = data["id"] # Role or user ID
990
+ self._type_val: int = int(data["type"]) # Store raw type for enum property
991
+ self.allow: str = data["allow"] # Bitwise value of allowed permissions
992
+ self.deny: str = data["deny"] # Bitwise value of denied permissions
993
+
994
+ @property
995
+ def type(self) -> "OverwriteType":
996
+ from .enums import (
997
+ OverwriteType,
998
+ ) # Local import to avoid circularity at module level
999
+
1000
+ return OverwriteType(self._type_val)
1001
+
1002
+ def to_dict(self) -> Dict[str, Any]:
1003
+ return {
1004
+ "id": self.id,
1005
+ "type": self.type.value,
1006
+ "allow": self.allow,
1007
+ "deny": self.deny,
1008
+ }
1009
+
1010
+ def __repr__(self) -> str:
1011
+ return f"<PermissionOverwrite id='{self.id}' type='{self.type.name if hasattr(self.type, 'name') else self._type_val}' allow='{self.allow}' deny='{self.deny}'>"
1012
+
1013
+
1014
+ class Guild:
1015
+ """Represents a Discord Guild (Server).
1016
+
1017
+ Attributes:
1018
+ id (str): Guild ID.
1019
+ name (str): Guild name (2-100 characters, excluding @, #, :, ```).
1020
+ icon (Optional[str]): Icon hash.
1021
+ splash (Optional[str]): Splash hash.
1022
+ discovery_splash (Optional[str]): Discovery splash hash; only present for discoverable guilds.
1023
+ owner (Optional[bool]): True if the user is the owner of the guild. (Only for /users/@me/guilds endpoint)
1024
+ owner_id (str): ID of owner.
1025
+ permissions (Optional[str]): Total permissions for the user in the guild (excludes overwrites). (Only for /users/@me/guilds endpoint)
1026
+ afk_channel_id (Optional[str]): ID of afk channel.
1027
+ afk_timeout (int): AFK timeout in seconds.
1028
+ widget_enabled (Optional[bool]): True if the server widget is enabled.
1029
+ widget_channel_id (Optional[str]): The channel id that the widget will generate an invite to, or null if set to no invite.
1030
+ verification_level (VerificationLevel): Verification level required for the guild.
1031
+ default_message_notifications (MessageNotificationLevel): Default message notifications level.
1032
+ explicit_content_filter (ExplicitContentFilterLevel): Explicit content filter level.
1033
+ roles (List[Role]): Roles in the guild.
1034
+ emojis (List[Dict]): Custom emojis. (Consider creating an Emoji model)
1035
+ features (List[GuildFeature]): Enabled guild features.
1036
+ mfa_level (MFALevel): Required MFA level for the guild.
1037
+ application_id (Optional[str]): Application ID of the guild creator if it is bot-created.
1038
+ system_channel_id (Optional[str]): The id of the channel where guild notices such as welcome messages and boost events are posted.
1039
+ system_channel_flags (int): System channel flags.
1040
+ rules_channel_id (Optional[str]): The id of the channel where Community guilds can display rules.
1041
+ max_members (Optional[int]): The maximum number of members for the guild.
1042
+ vanity_url_code (Optional[str]): The vanity url code for the guild.
1043
+ description (Optional[str]): The description of a Community guild.
1044
+ banner (Optional[str]): Banner hash.
1045
+ premium_tier (PremiumTier): Premium tier (Server Boost level).
1046
+ premium_subscription_count (Optional[int]): The number of boosts this guild currently has.
1047
+ preferred_locale (str): The preferred locale of a Community guild. Defaults to "en-US".
1048
+ public_updates_channel_id (Optional[str]): The id of the channel where admins and moderators of Community guilds receive notices from Discord.
1049
+ max_video_channel_users (Optional[int]): The maximum number of users in a video channel.
1050
+ welcome_screen (Optional[Dict]): The welcome screen of a Community guild. (Consider a WelcomeScreen model)
1051
+ nsfw_level (GuildNSFWLevel): Guild NSFW level.
1052
+ stickers (Optional[List[Dict]]): Custom stickers in the guild. (Consider a Sticker model)
1053
+ premium_progress_bar_enabled (bool): Whether the guild has the premium progress bar enabled.
1054
+ """
1055
+
1056
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1057
+ self._client: "Client" = client_instance
1058
+ self.id: str = data["id"]
1059
+ self.name: str = data["name"]
1060
+ self.icon: Optional[str] = data.get("icon")
1061
+ self.splash: Optional[str] = data.get("splash")
1062
+ self.discovery_splash: Optional[str] = data.get("discovery_splash")
1063
+ self.owner: Optional[bool] = data.get("owner")
1064
+ self.owner_id: str = data["owner_id"]
1065
+ self.permissions: Optional[str] = data.get("permissions")
1066
+ self.afk_channel_id: Optional[str] = data.get("afk_channel_id")
1067
+ self.afk_timeout: int = data["afk_timeout"]
1068
+ self.widget_enabled: Optional[bool] = data.get("widget_enabled")
1069
+ self.widget_channel_id: Optional[str] = data.get("widget_channel_id")
1070
+ self.verification_level: VerificationLevel = VerificationLevel(
1071
+ data["verification_level"]
1072
+ )
1073
+ self.default_message_notifications: MessageNotificationLevel = (
1074
+ MessageNotificationLevel(data["default_message_notifications"])
1075
+ )
1076
+ self.explicit_content_filter: ExplicitContentFilterLevel = (
1077
+ ExplicitContentFilterLevel(data["explicit_content_filter"])
1078
+ )
1079
+
1080
+ self.roles: List[Role] = [Role(r) for r in data.get("roles", [])]
1081
+ self.emojis: List[Emoji] = [
1082
+ Emoji(e_data, client_instance) for e_data in data.get("emojis", [])
1083
+ ]
1084
+
1085
+ # Assuming GuildFeature can be constructed from string feature names or their values
1086
+ self.features: List[GuildFeature] = [
1087
+ GuildFeature(f) if not isinstance(f, GuildFeature) else f
1088
+ for f in data.get("features", [])
1089
+ ]
1090
+
1091
+ self.mfa_level: MFALevel = MFALevel(data["mfa_level"])
1092
+ self.application_id: Optional[str] = data.get("application_id")
1093
+ self.system_channel_id: Optional[str] = data.get("system_channel_id")
1094
+ self.system_channel_flags: int = data["system_channel_flags"]
1095
+ self.rules_channel_id: Optional[str] = data.get("rules_channel_id")
1096
+ self.max_members: Optional[int] = data.get("max_members")
1097
+ self.vanity_url_code: Optional[str] = data.get("vanity_url_code")
1098
+ self.description: Optional[str] = data.get("description")
1099
+ self.banner: Optional[str] = data.get("banner")
1100
+ self.premium_tier: PremiumTier = PremiumTier(data["premium_tier"])
1101
+ self.premium_subscription_count: Optional[int] = data.get(
1102
+ "premium_subscription_count"
1103
+ )
1104
+ self.preferred_locale: str = data.get("preferred_locale", "en-US")
1105
+ self.public_updates_channel_id: Optional[str] = data.get(
1106
+ "public_updates_channel_id"
1107
+ )
1108
+ self.max_video_channel_users: Optional[int] = data.get(
1109
+ "max_video_channel_users"
1110
+ )
1111
+ self.approximate_member_count: Optional[int] = data.get(
1112
+ "approximate_member_count"
1113
+ )
1114
+ self.approximate_presence_count: Optional[int] = data.get(
1115
+ "approximate_presence_count"
1116
+ )
1117
+ self.welcome_screen: Optional["WelcomeScreen"] = (
1118
+ WelcomeScreen(data["welcome_screen"], client_instance)
1119
+ if data.get("welcome_screen")
1120
+ else None
1121
+ )
1122
+ self.nsfw_level: GuildNSFWLevel = GuildNSFWLevel(data["nsfw_level"])
1123
+ self.stickers: Optional[List[Sticker]] = (
1124
+ [Sticker(s_data, client_instance) for s_data in data.get("stickers", [])]
1125
+ if data.get("stickers")
1126
+ else None
1127
+ )
1128
+ self.premium_progress_bar_enabled: bool = data.get(
1129
+ "premium_progress_bar_enabled", False
1130
+ )
1131
+
1132
+ # Internal caches, populated by events or specific fetches
1133
+ self._channels: ChannelCache = ChannelCache()
1134
+ self._members: MemberCache = MemberCache(
1135
+ getattr(client_instance, "member_cache_flags", MemberCacheFlags())
1136
+ )
1137
+ self._threads: Dict[str, "Thread"] = {}
1138
+
1139
+ def get_channel(self, channel_id: str) -> Optional["Channel"]:
1140
+ return self._channels.get(channel_id)
1141
+
1142
+ def get_member(self, user_id: str) -> Optional[Member]:
1143
+ return self._members.get(user_id)
1144
+
1145
+ def get_member_named(self, name: str) -> Optional[Member]:
1146
+ """Retrieve a cached member by username or nickname.
1147
+
1148
+ The lookup is case-insensitive and searches both the username and
1149
+ guild nickname for a match.
1150
+
1151
+ Parameters
1152
+ ----------
1153
+ name: str
1154
+ The username or nickname to search for.
1155
+
1156
+ Returns
1157
+ -------
1158
+ Optional[Member]
1159
+ The matching member if found, otherwise ``None``.
1160
+ """
1161
+
1162
+ lowered = name.lower()
1163
+ for member in self._members.values():
1164
+ if member.username.lower() == lowered:
1165
+ return member
1166
+ if member.nick and member.nick.lower() == lowered:
1167
+ return member
1168
+ return None
1169
+
1170
+ def get_role(self, role_id: str) -> Optional[Role]:
1171
+ return next((role for role in self.roles if role.id == role_id), None)
1172
+
1173
+ def __repr__(self) -> str:
1174
+ return f"<Guild id='{self.id}' name='{self.name}'>"
1175
+
1176
+ async def fetch_widget(self) -> Dict[str, Any]:
1177
+ """|coro| Fetch this guild's widget settings."""
1178
+
1179
+ return await self._client.fetch_widget(self.id)
1180
+
1181
+ async def edit_widget(self, payload: Dict[str, Any]) -> Dict[str, Any]:
1182
+ """|coro| Edit this guild's widget settings."""
1183
+
1184
+ return await self._client.edit_widget(self.id, payload)
1185
+
1186
+ async def fetch_members(self, *, limit: Optional[int] = None) -> List["Member"]:
1187
+ """|coro|
1188
+
1189
+ Fetches all members for this guild.
1190
+
1191
+ This requires the ``GUILD_MEMBERS`` intent.
1192
+
1193
+ Parameters
1194
+ ----------
1195
+ limit: Optional[int]
1196
+ The maximum number of members to fetch. If ``None``, all members
1197
+ are fetched.
1198
+
1199
+ Returns
1200
+ -------
1201
+ List[Member]
1202
+ A list of all members in the guild.
1203
+
1204
+ Raises
1205
+ ------
1206
+ DisagreementException
1207
+ The gateway is not available to make the request.
1208
+ asyncio.TimeoutError
1209
+ The request timed out.
1210
+ """
1211
+ if not self._client._gateway:
1212
+ raise DisagreementException("Gateway not available for member fetching.")
1213
+
1214
+ nonce = str(asyncio.get_running_loop().time())
1215
+ future = self._client._gateway._loop.create_future()
1216
+ self._client._gateway._member_chunk_requests[nonce] = future
1217
+
1218
+ try:
1219
+ await self._client._gateway.request_guild_members(
1220
+ self.id, limit=limit or 0, nonce=nonce
1221
+ )
1222
+ member_data = await asyncio.wait_for(future, timeout=60.0)
1223
+ return [Member(m, self._client) for m in member_data]
1224
+ except asyncio.TimeoutError:
1225
+ if nonce in self._client._gateway._member_chunk_requests:
1226
+ del self._client._gateway._member_chunk_requests[nonce]
1227
+ raise
1228
+
1229
+
1230
+ class Channel:
1231
+ """Base class for Discord channels."""
1232
+
1233
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1234
+ self._client: "Client" = client_instance
1235
+ self.id: str = data["id"]
1236
+ self._type_val: int = int(data["type"]) # Store raw type for enum property
1237
+
1238
+ self.guild_id: Optional[str] = data.get("guild_id")
1239
+ self.name: Optional[str] = data.get("name")
1240
+ self.position: Optional[int] = data.get("position")
1241
+ self.permission_overwrites: List["PermissionOverwrite"] = [
1242
+ PermissionOverwrite(d) for d in data.get("permission_overwrites", [])
1243
+ ]
1244
+ self.nsfw: Optional[bool] = data.get("nsfw", False)
1245
+ self.parent_id: Optional[str] = data.get(
1246
+ "parent_id"
1247
+ ) # ID of the parent category channel or thread parent
1248
+
1249
+ @property
1250
+ def type(self) -> ChannelType:
1251
+ return ChannelType(self._type_val)
1252
+
1253
+ @property
1254
+ def mention(self) -> str:
1255
+ return f"<#{self.id}>"
1256
+
1257
+ async def delete(self, reason: Optional[str] = None):
1258
+ await self._client._http.delete_channel(self.id, reason=reason)
1259
+
1260
+ def __repr__(self) -> str:
1261
+ return f"<Channel id='{self.id}' name='{self.name}' type='{self.type.name if hasattr(self.type, 'name') else self._type_val}'>"
1262
+
1263
+ def permission_overwrite_for(
1264
+ self, target: Union["Role", "Member", str]
1265
+ ) -> Optional["PermissionOverwrite"]:
1266
+ """Return the :class:`PermissionOverwrite` for ``target`` if present."""
1267
+
1268
+ if isinstance(target, str):
1269
+ target_id = target
1270
+ else:
1271
+ target_id = target.id
1272
+ for overwrite in self.permission_overwrites:
1273
+ if overwrite.id == target_id:
1274
+ return overwrite
1275
+ return None
1276
+
1277
+ @staticmethod
1278
+ def _apply_overwrite(
1279
+ perms: Permissions, overwrite: Optional["PermissionOverwrite"]
1280
+ ) -> Permissions:
1281
+ if overwrite is None:
1282
+ return perms
1283
+
1284
+ perms &= ~Permissions(int(overwrite.deny))
1285
+ perms |= Permissions(int(overwrite.allow))
1286
+ return perms
1287
+
1288
+ def permissions_for(self, member: "Member") -> Permissions:
1289
+ """Resolve channel permissions for ``member``."""
1290
+
1291
+ if self.guild_id is None:
1292
+ return Permissions(~0)
1293
+
1294
+ if not hasattr(self._client, "get_guild"):
1295
+ return Permissions(0)
1296
+
1297
+ guild = self._client.get_guild(self.guild_id)
1298
+ if guild is None:
1299
+ return Permissions(0)
1300
+
1301
+ base = Permissions(0)
1302
+
1303
+ everyone = guild.get_role(guild.id)
1304
+ if everyone is not None:
1305
+ base |= Permissions(int(everyone.permissions))
1306
+
1307
+ for rid in member.roles:
1308
+ role = guild.get_role(rid)
1309
+ if role is not None:
1310
+ base |= Permissions(int(role.permissions))
1311
+
1312
+ if base & Permissions.ADMINISTRATOR:
1313
+ return Permissions(~0)
1314
+
1315
+ # Apply @everyone overwrite
1316
+ base = self._apply_overwrite(base, self.permission_overwrite_for(guild.id))
1317
+
1318
+ # Role overwrites
1319
+ role_allow = Permissions(0)
1320
+ role_deny = Permissions(0)
1321
+ for rid in member.roles:
1322
+ ow = self.permission_overwrite_for(rid)
1323
+ if ow is not None:
1324
+ role_allow |= Permissions(int(ow.allow))
1325
+ role_deny |= Permissions(int(ow.deny))
1326
+
1327
+ base &= ~role_deny
1328
+ base |= role_allow
1329
+
1330
+ # Member overwrite
1331
+ base = self._apply_overwrite(base, self.permission_overwrite_for(member.id))
1332
+
1333
+ return base
1334
+
1335
+
1336
+ class Messageable:
1337
+ """Mixin for channels that can send messages and show typing."""
1338
+
1339
+ _client: "Client"
1340
+ id: str
1341
+
1342
+ async def send(
1343
+ self,
1344
+ content: Optional[str] = None,
1345
+ *,
1346
+ embed: Optional["Embed"] = None,
1347
+ embeds: Optional[List["Embed"]] = None,
1348
+ components: Optional[List["ActionRow"]] = None,
1349
+ ) -> "Message":
1350
+ if not hasattr(self._client, "send_message"):
1351
+ raise NotImplementedError(
1352
+ "Client.send_message is required for Messageable.send"
1353
+ )
1354
+
1355
+ return await self._client.send_message(
1356
+ channel_id=self.id,
1357
+ content=content,
1358
+ embed=embed,
1359
+ embeds=embeds,
1360
+ components=components,
1361
+ )
1362
+
1363
+ async def trigger_typing(self) -> None:
1364
+ await self._client._http.trigger_typing(self.id)
1365
+
1366
+ def typing(self) -> "Typing":
1367
+ if not hasattr(self._client, "typing"):
1368
+ raise NotImplementedError(
1369
+ "Client.typing is required for Messageable.typing"
1370
+ )
1371
+ return self._client.typing(self.id)
1372
+
1373
+
1374
+ class TextChannel(Channel, Messageable):
1375
+ """Represents a guild text channel or announcement channel."""
1376
+
1377
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1378
+ super().__init__(data, client_instance)
1379
+ self.topic: Optional[str] = data.get("topic")
1380
+ self.last_message_id: Optional[str] = data.get("last_message_id")
1381
+ self.rate_limit_per_user: Optional[int] = data.get("rate_limit_per_user", 0)
1382
+ self.default_auto_archive_duration: Optional[int] = data.get(
1383
+ "default_auto_archive_duration"
1384
+ )
1385
+ self.last_pin_timestamp: Optional[str] = data.get("last_pin_timestamp")
1386
+
1387
+ def history(
1388
+ self,
1389
+ *,
1390
+ limit: Optional[int] = None,
1391
+ before: Optional[str] = None,
1392
+ after: Optional[str] = None,
1393
+ ) -> AsyncIterator["Message"]:
1394
+ """Return an async iterator over this channel's messages."""
1395
+
1396
+ from .utils import message_pager
1397
+
1398
+ return message_pager(self, limit=limit, before=before, after=after)
1399
+
1400
+ async def purge(
1401
+ self, limit: int, *, before: "Snowflake | None" = None
1402
+ ) -> List["Snowflake"]:
1403
+ """Bulk delete messages from this channel."""
1404
+
1405
+ params: Dict[str, Union[int, str]] = {"limit": limit}
1406
+ if before is not None:
1407
+ params["before"] = before
1408
+
1409
+ messages = await self._client._http.request(
1410
+ "GET", f"/channels/{self.id}/messages", params=params
1411
+ )
1412
+ ids = [m["id"] for m in messages]
1413
+ if not ids:
1414
+ return []
1415
+
1416
+ await self._client._http.bulk_delete_messages(self.id, ids)
1417
+ for mid in ids:
1418
+ self._client._messages.invalidate(mid)
1419
+ return ids
1420
+
1421
+ def get_partial_message(self, id: int) -> "PartialMessage":
1422
+ """Returns a :class:`PartialMessage` for the given ID.
1423
+
1424
+ This allows performing actions on a message without fetching it first.
1425
+
1426
+ Parameters
1427
+ ----------
1428
+ id: int
1429
+ The ID of the message to get a partial instance of.
1430
+
1431
+ Returns
1432
+ -------
1433
+ PartialMessage
1434
+ The partial message instance.
1435
+ """
1436
+ return PartialMessage(id=str(id), channel=self)
1437
+
1438
+ def __repr__(self) -> str:
1439
+ return f"<TextChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
1440
+
1441
+ async def pins(self) -> List["Message"]:
1442
+ """|coro|
1443
+
1444
+ Fetches all pinned messages in this channel.
1445
+
1446
+ Returns
1447
+ -------
1448
+ List[Message]
1449
+ The pinned messages.
1450
+
1451
+ Raises
1452
+ ------
1453
+ HTTPException
1454
+ Fetching the pinned messages failed.
1455
+ """
1456
+
1457
+ messages_data = await self._client._http.get_pinned_messages(self.id)
1458
+ return [self._client.parse_message(m) for m in messages_data]
1459
+
1460
+ async def create_thread(
1461
+ self,
1462
+ name: str,
1463
+ *,
1464
+ type: ChannelType = ChannelType.PUBLIC_THREAD,
1465
+ auto_archive_duration: Optional[AutoArchiveDuration] = None,
1466
+ invitable: Optional[bool] = None,
1467
+ rate_limit_per_user: Optional[int] = None,
1468
+ reason: Optional[str] = None,
1469
+ ) -> "Thread":
1470
+ """|coro|
1471
+
1472
+ Creates a new thread in this channel.
1473
+
1474
+ Parameters
1475
+ ----------
1476
+ name: str
1477
+ The name of the thread.
1478
+ type: ChannelType
1479
+ The type of thread to create. Defaults to PUBLIC_THREAD.
1480
+ Can be PUBLIC_THREAD, PRIVATE_THREAD, or ANNOUNCEMENT_THREAD.
1481
+ auto_archive_duration: Optional[AutoArchiveDuration]
1482
+ How long before the thread is automatically archived after recent activity.
1483
+ invitable: Optional[bool]
1484
+ Whether non-moderators can invite other non-moderators to a private thread.
1485
+ Only applicable to private threads.
1486
+ rate_limit_per_user: Optional[int]
1487
+ The number of seconds a user has to wait before sending another message.
1488
+ reason: Optional[str]
1489
+ The reason for creating the thread.
1490
+
1491
+ Returns
1492
+ -------
1493
+ Thread
1494
+ The created thread.
1495
+ """
1496
+ payload: Dict[str, Any] = {
1497
+ "name": name,
1498
+ "type": type.value,
1499
+ }
1500
+ if auto_archive_duration is not None:
1501
+ payload["auto_archive_duration"] = int(auto_archive_duration)
1502
+ if invitable is not None and type == ChannelType.PRIVATE_THREAD:
1503
+ payload["invitable"] = invitable
1504
+ if rate_limit_per_user is not None:
1505
+ payload["rate_limit_per_user"] = rate_limit_per_user
1506
+
1507
+ data = await self._client._http.start_thread_without_message(self.id, payload)
1508
+ return cast("Thread", self._client.parse_channel(data))
1509
+
1510
+
1511
+ class VoiceChannel(Channel):
1512
+ """Represents a guild voice channel or stage voice channel."""
1513
+
1514
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1515
+ super().__init__(data, client_instance)
1516
+ self.bitrate: int = data.get("bitrate", 64000)
1517
+ self.user_limit: int = data.get("user_limit", 0)
1518
+ self.rtc_region: Optional[str] = data.get("rtc_region")
1519
+ self.video_quality_mode: Optional[int] = data.get("video_quality_mode")
1520
+
1521
+ def __repr__(self) -> str:
1522
+ return f"<VoiceChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
1523
+
1524
+
1525
+ class StageChannel(VoiceChannel):
1526
+ """Represents a guild stage channel."""
1527
+
1528
+ def __repr__(self) -> str:
1529
+ return f"<StageChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
1530
+
1531
+ async def start_stage_instance(
1532
+ self,
1533
+ topic: str,
1534
+ *,
1535
+ privacy_level: int = 2,
1536
+ reason: Optional[str] = None,
1537
+ guild_scheduled_event_id: Optional[str] = None,
1538
+ ) -> "StageInstance":
1539
+ if not hasattr(self._client, "_http"):
1540
+ raise DisagreementException("Client missing HTTP for stage instance")
1541
+
1542
+ payload: Dict[str, Any] = {
1543
+ "channel_id": self.id,
1544
+ "topic": topic,
1545
+ "privacy_level": privacy_level,
1546
+ }
1547
+ if guild_scheduled_event_id is not None:
1548
+ payload["guild_scheduled_event_id"] = guild_scheduled_event_id
1549
+
1550
+ instance = await self._client._http.start_stage_instance(payload, reason=reason)
1551
+ instance._client = self._client
1552
+ return instance
1553
+
1554
+ async def edit_stage_instance(
1555
+ self,
1556
+ *,
1557
+ topic: Optional[str] = None,
1558
+ privacy_level: Optional[int] = None,
1559
+ reason: Optional[str] = None,
1560
+ ) -> "StageInstance":
1561
+ if not hasattr(self._client, "_http"):
1562
+ raise DisagreementException("Client missing HTTP for stage instance")
1563
+
1564
+ payload: Dict[str, Any] = {}
1565
+ if topic is not None:
1566
+ payload["topic"] = topic
1567
+ if privacy_level is not None:
1568
+ payload["privacy_level"] = privacy_level
1569
+
1570
+ instance = await self._client._http.edit_stage_instance(
1571
+ self.id, payload, reason=reason
1572
+ )
1573
+ instance._client = self._client
1574
+ return instance
1575
+
1576
+ async def end_stage_instance(self, *, reason: Optional[str] = None) -> None:
1577
+ if not hasattr(self._client, "_http"):
1578
+ raise DisagreementException("Client missing HTTP for stage instance")
1579
+
1580
+ await self._client._http.end_stage_instance(self.id, reason=reason)
1581
+
1582
+
1583
+ class StageInstance:
1584
+ """Represents a stage instance."""
1585
+
1586
+ def __init__(
1587
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1588
+ ) -> None:
1589
+ self._client = client_instance
1590
+ self.id: str = data["id"]
1591
+ self.guild_id: Optional[str] = data.get("guild_id")
1592
+ self.channel_id: str = data["channel_id"]
1593
+ self.topic: str = data["topic"]
1594
+ self.privacy_level: int = data.get("privacy_level", 2)
1595
+ self.discoverable_disabled: bool = data.get("discoverable_disabled", False)
1596
+ self.guild_scheduled_event_id: Optional[str] = data.get(
1597
+ "guild_scheduled_event_id"
1598
+ )
1599
+
1600
+ def __repr__(self) -> str:
1601
+ return f"<StageInstance id='{self.id}' channel_id='{self.channel_id}'>"
1602
+
1603
+
1604
+ class CategoryChannel(Channel):
1605
+ """Represents a guild category channel."""
1606
+
1607
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1608
+ super().__init__(data, client_instance)
1609
+
1610
+ @property
1611
+ def channels(self) -> List[Channel]:
1612
+ if not self.guild_id or not hasattr(self._client, "get_guild"):
1613
+ return []
1614
+ guild = self._client.get_guild(self.guild_id)
1615
+ if not guild or not hasattr(
1616
+ guild, "_channels"
1617
+ ): # Ensure guild and _channels exist
1618
+ return []
1619
+
1620
+ categorized_channels = [
1621
+ ch
1622
+ for ch in guild._channels.values()
1623
+ if getattr(ch, "parent_id", None) == self.id
1624
+ ]
1625
+ return sorted(
1626
+ categorized_channels,
1627
+ key=lambda c: c.position if c.position is not None else -1,
1628
+ )
1629
+
1630
+ def __repr__(self) -> str:
1631
+ return f"<CategoryChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
1632
+
1633
+
1634
+ class ThreadMetadata:
1635
+ """Represents the metadata of a thread."""
1636
+
1637
+ def __init__(self, data: Dict[str, Any]):
1638
+ self.archived: bool = data["archived"]
1639
+ self.auto_archive_duration: int = data["auto_archive_duration"]
1640
+ self.archive_timestamp: str = data["archive_timestamp"]
1641
+ self.locked: bool = data["locked"]
1642
+ self.invitable: Optional[bool] = data.get("invitable")
1643
+ self.create_timestamp: Optional[str] = data.get("create_timestamp")
1644
+
1645
+
1646
+ class Thread(TextChannel): # Threads are a specialized TextChannel
1647
+ """Represents a Discord Thread."""
1648
+
1649
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1650
+ super().__init__(data, client_instance) # Handles common text channel fields
1651
+ self.owner_id: Optional[str] = data.get("owner_id")
1652
+ # parent_id is already handled by base Channel init if present in data
1653
+ self.message_count: Optional[int] = data.get("message_count")
1654
+ self.member_count: Optional[int] = data.get("member_count")
1655
+ self.thread_metadata: ThreadMetadata = ThreadMetadata(data["thread_metadata"])
1656
+ self.member: Optional["ThreadMember"] = (
1657
+ ThreadMember(data["member"], client_instance)
1658
+ if data.get("member")
1659
+ else None
1660
+ )
1661
+
1662
+ def __repr__(self) -> str:
1663
+ return (
1664
+ f"<Thread id='{self.id}' name='{self.name}' parent_id='{self.parent_id}'>"
1665
+ )
1666
+
1667
+ async def join(self) -> None:
1668
+ """|coro|
1669
+
1670
+ Joins this thread.
1671
+ """
1672
+ await self._client._http.join_thread(self.id)
1673
+
1674
+ async def leave(self) -> None:
1675
+ """|coro|
1676
+
1677
+ Leaves this thread.
1678
+ """
1679
+ await self._client._http.leave_thread(self.id)
1680
+
1681
+ async def archive(
1682
+ self, locked: bool = False, *, reason: Optional[str] = None
1683
+ ) -> "Thread":
1684
+ """|coro|
1685
+
1686
+ Archives this thread.
1687
+
1688
+ Parameters
1689
+ ----------
1690
+ locked: bool
1691
+ Whether to lock the thread.
1692
+ reason: Optional[str]
1693
+ The reason for archiving the thread.
1694
+
1695
+ Returns
1696
+ -------
1697
+ Thread
1698
+ The updated thread.
1699
+ """
1700
+ payload = {
1701
+ "archived": True,
1702
+ "locked": locked,
1703
+ }
1704
+ data = await self._client._http.edit_channel(self.id, payload, reason=reason)
1705
+ return cast("Thread", self._client.parse_channel(data))
1706
+
1707
+
1708
+ class DMChannel(Channel, Messageable):
1709
+ """Represents a Direct Message channel."""
1710
+
1711
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1712
+ super().__init__(data, client_instance)
1713
+ self.last_message_id: Optional[str] = data.get("last_message_id")
1714
+ self.recipients: List[User] = [
1715
+ User(u_data) for u_data in data.get("recipients", [])
1716
+ ]
1717
+
1718
+ @property
1719
+ def recipient(self) -> Optional[User]:
1720
+ return self.recipients[0] if self.recipients else None
1721
+
1722
+ async def history(
1723
+ self,
1724
+ *,
1725
+ limit: Optional[int] = 100,
1726
+ before: "Snowflake | None" = None,
1727
+ ):
1728
+ """An async iterator over messages in this DM."""
1729
+
1730
+ params: Dict[str, Union[int, str]] = {}
1731
+ if before is not None:
1732
+ params["before"] = before
1733
+
1734
+ fetched = 0
1735
+ while True:
1736
+ to_fetch = 100 if limit is None else min(100, limit - fetched)
1737
+ if to_fetch <= 0:
1738
+ break
1739
+ params["limit"] = to_fetch
1740
+ messages = await self._client._http.request(
1741
+ "GET", f"/channels/{self.id}/messages", params=params.copy()
1742
+ )
1743
+ if not messages:
1744
+ break
1745
+ params["before"] = messages[-1]["id"]
1746
+ for msg in messages:
1747
+ yield Message(msg, self._client)
1748
+ fetched += 1
1749
+ if limit is not None and fetched >= limit:
1750
+ return
1751
+
1752
+ def __repr__(self) -> str:
1753
+ recipient_repr = self.recipient.username if self.recipient else "Unknown"
1754
+ return f"<DMChannel id='{self.id}' recipient='{recipient_repr}'>"
1755
+
1756
+
1757
+ class PartialChannel:
1758
+ """Represents a partial channel object, often from interactions."""
1759
+
1760
+ def __init__(
1761
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1762
+ ):
1763
+ self._client: Optional["Client"] = client_instance
1764
+ self.id: str = data["id"]
1765
+ self.name: Optional[str] = data.get("name")
1766
+ self._type_val: int = int(data["type"])
1767
+ self.permissions: Optional[str] = data.get("permissions")
1768
+
1769
+ @property
1770
+ def type(self) -> ChannelType:
1771
+ return ChannelType(self._type_val)
1772
+
1773
+ @property
1774
+ def mention(self) -> str:
1775
+ return f"<#{self.id}>"
1776
+
1777
+ async def fetch_full_channel(self) -> Optional[Channel]:
1778
+ if not self._client or not hasattr(self._client, "fetch_channel"):
1779
+ # Log or raise if fetching is not possible
1780
+ return None
1781
+ try:
1782
+ # This assumes Client.fetch_channel exists and returns a full Channel object
1783
+ return await self._client.fetch_channel(self.id)
1784
+ except HTTPException as exc:
1785
+ print(f"HTTP error while fetching channel {self.id}: {exc}")
1786
+ except (json.JSONDecodeError, KeyError, ValueError) as exc:
1787
+ print(f"Failed to parse channel {self.id}: {exc}")
1788
+ except DisagreementException as exc:
1789
+ print(f"Error fetching channel {self.id}: {exc}")
1790
+ return None
1791
+
1792
+ def __repr__(self) -> str:
1793
+ type_name = self.type.name if hasattr(self.type, "name") else self._type_val
1794
+ return f"<PartialChannel id='{self.id}' name='{self.name}' type='{type_name}'>"
1795
+
1796
+
1797
+ class Webhook:
1798
+ """Represents a Discord Webhook."""
1799
+
1800
+ def __init__(
1801
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1802
+ ):
1803
+ self._client: Optional["Client"] = client_instance
1804
+ self.id: str = data["id"]
1805
+ self.type: int = int(data.get("type", 1))
1806
+ self.guild_id: Optional[str] = data.get("guild_id")
1807
+ self.channel_id: Optional[str] = data.get("channel_id")
1808
+ self.name: Optional[str] = data.get("name")
1809
+ self.avatar: Optional[str] = data.get("avatar")
1810
+ self.token: Optional[str] = data.get("token")
1811
+ self.application_id: Optional[str] = data.get("application_id")
1812
+ self.url: Optional[str] = data.get("url")
1813
+ self.user: Optional[User] = User(data["user"]) if data.get("user") else None
1814
+
1815
+ def __repr__(self) -> str:
1816
+ return f"<Webhook id='{self.id}' name='{self.name}'>"
1817
+
1818
+ @classmethod
1819
+ def from_url(
1820
+ cls, url: str, session: Optional[aiohttp.ClientSession] = None
1821
+ ) -> "Webhook":
1822
+ """Create a minimal :class:`Webhook` from a webhook URL.
1823
+
1824
+ Parameters
1825
+ ----------
1826
+ url:
1827
+ The full Discord webhook URL.
1828
+ session:
1829
+ Unused for now. Present for API compatibility.
1830
+
1831
+ Returns
1832
+ -------
1833
+ Webhook
1834
+ A webhook instance containing only the ``id``, ``token`` and ``url``.
1835
+ """
1836
+
1837
+ parts = url.rstrip("/").split("/")
1838
+ if len(parts) < 2:
1839
+ raise ValueError("Invalid webhook URL")
1840
+ token = parts[-1]
1841
+ webhook_id = parts[-2]
1842
+
1843
+ return cls({"id": webhook_id, "token": token, "url": url})
1844
+
1845
+ async def send(
1846
+ self,
1847
+ content: Optional[str] = None,
1848
+ *,
1849
+ username: Optional[str] = None,
1850
+ avatar_url: Optional[str] = None,
1851
+ tts: bool = False,
1852
+ embed: Optional["Embed"] = None,
1853
+ embeds: Optional[List["Embed"]] = None,
1854
+ components: Optional[List["ActionRow"]] = None,
1855
+ allowed_mentions: Optional[Dict[str, Any]] = None,
1856
+ attachments: Optional[List[Any]] = None,
1857
+ files: Optional[List[Any]] = None,
1858
+ flags: Optional[int] = None,
1859
+ ) -> "Message":
1860
+ """Send a message using this webhook."""
1861
+
1862
+ if not self._client:
1863
+ raise DisagreementException("Webhook is not bound to a Client")
1864
+ assert self.token is not None, "Webhook token missing"
1865
+
1866
+ if embed and embeds:
1867
+ raise ValueError("Cannot provide both embed and embeds.")
1868
+
1869
+ final_embeds_payload: Optional[List[Dict[str, Any]]] = None
1870
+ if embed:
1871
+ final_embeds_payload = [embed.to_dict()]
1872
+ elif embeds:
1873
+ final_embeds_payload = [e.to_dict() for e in embeds]
1874
+
1875
+ components_payload: Optional[List[Dict[str, Any]]] = None
1876
+ if components:
1877
+ components_payload = [c.to_dict() for c in components]
1878
+
1879
+ message_data = await self._client._http.execute_webhook(
1880
+ self.id,
1881
+ self.token,
1882
+ content=content,
1883
+ tts=tts,
1884
+ embeds=final_embeds_payload,
1885
+ components=components_payload,
1886
+ allowed_mentions=allowed_mentions,
1887
+ attachments=attachments,
1888
+ files=files,
1889
+ flags=flags,
1890
+ username=username,
1891
+ avatar_url=avatar_url,
1892
+ )
1893
+
1894
+ return self._client.parse_message(message_data)
1895
+
1896
+
1897
+ class GuildTemplate:
1898
+ """Represents a guild template."""
1899
+
1900
+ def __init__(
1901
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1902
+ ):
1903
+ self._client = client_instance
1904
+ self.code: str = data["code"]
1905
+ self.name: str = data["name"]
1906
+ self.description: Optional[str] = data.get("description")
1907
+ self.usage_count: int = data.get("usage_count", 0)
1908
+ self.creator_id: str = data.get("creator_id", "")
1909
+ self.creator: Optional[User] = (
1910
+ User(data["creator"]) if data.get("creator") else None
1911
+ )
1912
+ self.created_at: Optional[str] = data.get("created_at")
1913
+ self.updated_at: Optional[str] = data.get("updated_at")
1914
+ self.source_guild_id: Optional[str] = data.get("source_guild_id")
1915
+ self.serialized_source_guild: Dict[str, Any] = data.get(
1916
+ "serialized_source_guild", {}
1917
+ )
1918
+ self.is_dirty: Optional[bool] = data.get("is_dirty")
1919
+
1920
+ def __repr__(self) -> str:
1921
+ return f"<GuildTemplate code='{self.code}' name='{self.name}'>"
1922
+
1923
+
1924
+ # --- Message Components ---
1925
+
1926
+
1927
+ class Component:
1928
+ """Base class for message components."""
1929
+
1930
+ def __init__(self, type: ComponentType):
1931
+ self.type: ComponentType = type
1932
+ self.custom_id: Optional[str] = None
1933
+
1934
+ def to_dict(self) -> Dict[str, Any]:
1935
+ payload: Dict[str, Any] = {"type": self.type.value}
1936
+ if self.custom_id:
1937
+ payload["custom_id"] = self.custom_id
1938
+ return payload
1939
+
1940
+
1941
+ class ActionRow(Component):
1942
+ """Represents an Action Row, a container for other components."""
1943
+
1944
+ def __init__(self, components: Optional[List[Component]] = None):
1945
+ super().__init__(ComponentType.ACTION_ROW)
1946
+ self.components: List[Component] = components or []
1947
+
1948
+ def add_component(self, component: Component):
1949
+ if isinstance(component, ActionRow):
1950
+ raise ValueError("Cannot nest ActionRows inside another ActionRow.")
1951
+
1952
+ select_types = {
1953
+ ComponentType.STRING_SELECT,
1954
+ ComponentType.USER_SELECT,
1955
+ ComponentType.ROLE_SELECT,
1956
+ ComponentType.MENTIONABLE_SELECT,
1957
+ ComponentType.CHANNEL_SELECT,
1958
+ }
1959
+
1960
+ if component.type in select_types:
1961
+ if self.components:
1962
+ raise ValueError(
1963
+ "Select menu components must be the only component in an ActionRow."
1964
+ )
1965
+ self.components.append(component)
1966
+ return self
1967
+
1968
+ if any(c.type in select_types for c in self.components):
1969
+ raise ValueError(
1970
+ "Cannot add components to an ActionRow that already contains a select menu."
1971
+ )
1972
+
1973
+ if len(self.components) >= 5:
1974
+ raise ValueError("ActionRow cannot have more than 5 components.")
1975
+
1976
+ self.components.append(component)
1977
+ return self
1978
+
1979
+ def to_dict(self) -> Dict[str, Any]:
1980
+ payload = super().to_dict()
1981
+ payload["components"] = [c.to_dict() for c in self.components]
1982
+ return payload
1983
+
1984
+ @classmethod
1985
+ def from_dict(
1986
+ cls, data: Dict[str, Any], client: Optional["Client"] = None
1987
+ ) -> "ActionRow":
1988
+ """Deserialize an action row payload."""
1989
+ from .components import component_factory
1990
+
1991
+ row = cls()
1992
+ for comp_data in data.get("components", []):
1993
+ try:
1994
+ row.add_component(component_factory(comp_data, client))
1995
+ except Exception:
1996
+ # Skip components that fail to parse for now
1997
+ continue
1998
+ return row
1999
+
2000
+
2001
+ class Button(Component):
2002
+ """Represents a button component."""
2003
+
2004
+ def __init__(
2005
+ self,
2006
+ *, # Make parameters keyword-only for clarity
2007
+ style: ButtonStyle,
2008
+ label: Optional[str] = None,
2009
+ emoji: Optional["PartialEmoji"] = None, # Changed to PartialEmoji type
2010
+ custom_id: Optional[str] = None,
2011
+ url: Optional[str] = None,
2012
+ disabled: bool = False,
2013
+ ):
2014
+ super().__init__(ComponentType.BUTTON)
2015
+
2016
+ if style == ButtonStyle.LINK and url is None:
2017
+ raise ValueError("Link buttons must have a URL.")
2018
+ if style != ButtonStyle.LINK and custom_id is None:
2019
+ raise ValueError("Non-link buttons must have a custom_id.")
2020
+ if label is None and emoji is None:
2021
+ raise ValueError("Button must have a label or an emoji.")
2022
+
2023
+ self.style: ButtonStyle = style
2024
+ self.label: Optional[str] = label
2025
+ self.emoji: Optional[PartialEmoji] = emoji
2026
+ self.custom_id = custom_id
2027
+ self.url: Optional[str] = url
2028
+ self.disabled: bool = disabled
2029
+
2030
+ def to_dict(self) -> Dict[str, Any]:
2031
+ payload = super().to_dict()
2032
+ payload["style"] = self.style.value
2033
+ if self.label:
2034
+ payload["label"] = self.label
2035
+ if self.emoji:
2036
+ payload["emoji"] = self.emoji.to_dict() # Call to_dict()
2037
+ if self.custom_id:
2038
+ payload["custom_id"] = self.custom_id
2039
+ if self.url:
2040
+ payload["url"] = self.url
2041
+ if self.disabled:
2042
+ payload["disabled"] = self.disabled
2043
+ return payload
2044
+
2045
+
2046
+ class SelectOption:
2047
+ """Represents an option in a select menu."""
2048
+
2049
+ def __init__(
2050
+ self,
2051
+ *, # Make parameters keyword-only
2052
+ label: str,
2053
+ value: str,
2054
+ description: Optional[str] = None,
2055
+ emoji: Optional["PartialEmoji"] = None, # Changed to PartialEmoji type
2056
+ default: bool = False,
2057
+ ):
2058
+ self.label: str = label
2059
+ self.value: str = value
2060
+ self.description: Optional[str] = description
2061
+ self.emoji: Optional["PartialEmoji"] = emoji
2062
+ self.default: bool = default
2063
+
2064
+ def to_dict(self) -> Dict[str, Any]:
2065
+ payload: Dict[str, Any] = {
2066
+ "label": self.label,
2067
+ "value": self.value,
2068
+ }
2069
+ if self.description:
2070
+ payload["description"] = self.description
2071
+ if self.emoji:
2072
+ payload["emoji"] = self.emoji.to_dict() # Call to_dict()
2073
+ if self.default:
2074
+ payload["default"] = self.default
2075
+ return payload
2076
+
2077
+
2078
+ class SelectMenu(Component):
2079
+ """Represents a select menu component.
2080
+
2081
+ Currently supports STRING_SELECT (type 3).
2082
+ User (5), Role (6), Mentionable (7), Channel (8) selects are not yet fully modeled.
2083
+ """
2084
+
2085
+ def __init__(
2086
+ self,
2087
+ *, # Make parameters keyword-only
2088
+ custom_id: str,
2089
+ options: List[SelectOption],
2090
+ placeholder: Optional[str] = None,
2091
+ min_values: int = 1,
2092
+ max_values: int = 1,
2093
+ disabled: bool = False,
2094
+ channel_types: Optional[List[ChannelType]] = None,
2095
+ # For other select types, specific fields would be needed.
2096
+ # This constructor primarily targets STRING_SELECT (type 3).
2097
+ type: ComponentType = ComponentType.STRING_SELECT, # Default to string select
2098
+ ):
2099
+ super().__init__(type) # Pass the specific select menu type
2100
+
2101
+ if not (1 <= len(options) <= 25):
2102
+ raise ValueError("Select menu must have between 1 and 25 options.")
2103
+ if not (
2104
+ 0 <= min_values <= 25
2105
+ ): # Discord docs say min_values can be 0 for some types
2106
+ raise ValueError("min_values must be between 0 and 25.")
2107
+ if not (1 <= max_values <= 25):
2108
+ raise ValueError("max_values must be between 1 and 25.")
2109
+ if min_values > max_values:
2110
+ raise ValueError("min_values cannot be greater than max_values.")
2111
+
2112
+ self.custom_id = custom_id
2113
+ self.options: List[SelectOption] = options
2114
+ self.placeholder: Optional[str] = placeholder
2115
+ self.min_values: int = min_values
2116
+ self.max_values: int = max_values
2117
+ self.disabled: bool = disabled
2118
+ self.channel_types: Optional[List[ChannelType]] = channel_types
2119
+
2120
+ def to_dict(self) -> Dict[str, Any]:
2121
+ payload = super().to_dict() # Gets {"type": self.type.value}
2122
+ payload["custom_id"] = self.custom_id
2123
+ payload["options"] = [opt.to_dict() for opt in self.options]
2124
+ if self.placeholder:
2125
+ payload["placeholder"] = self.placeholder
2126
+ payload["min_values"] = self.min_values
2127
+ payload["max_values"] = self.max_values
2128
+ if self.disabled:
2129
+ payload["disabled"] = self.disabled
2130
+ if self.type == ComponentType.CHANNEL_SELECT and self.channel_types:
2131
+ payload["channel_types"] = [ct.value for ct in self.channel_types]
2132
+ return payload
2133
+
2134
+
2135
+ class UnfurledMediaItem:
2136
+ """Represents an unfurled media item."""
2137
+
2138
+ def __init__(
2139
+ self,
2140
+ url: str,
2141
+ proxy_url: Optional[str] = None,
2142
+ height: Optional[int] = None,
2143
+ width: Optional[int] = None,
2144
+ content_type: Optional[str] = None,
2145
+ ):
2146
+ self.url = url
2147
+ self.proxy_url = proxy_url
2148
+ self.height = height
2149
+ self.width = width
2150
+ self.content_type = content_type
2151
+
2152
+ def to_dict(self) -> Dict[str, Any]:
2153
+ return {
2154
+ "url": self.url,
2155
+ "proxy_url": self.proxy_url,
2156
+ "height": self.height,
2157
+ "width": self.width,
2158
+ "content_type": self.content_type,
2159
+ }
2160
+
2161
+
2162
+ class MediaGalleryItem:
2163
+ """Represents an item in a media gallery."""
2164
+
2165
+ def __init__(
2166
+ self,
2167
+ media: UnfurledMediaItem,
2168
+ description: Optional[str] = None,
2169
+ spoiler: bool = False,
2170
+ ):
2171
+ self.media = media
2172
+ self.description = description
2173
+ self.spoiler = spoiler
2174
+
2175
+ def to_dict(self) -> Dict[str, Any]:
2176
+ return {
2177
+ "media": self.media.to_dict(),
2178
+ "description": self.description,
2179
+ "spoiler": self.spoiler,
2180
+ }
2181
+
2182
+
2183
+ class TextDisplay(Component):
2184
+ """Represents a text display component."""
2185
+
2186
+ def __init__(self, content: str, id: Optional[int] = None):
2187
+ super().__init__(ComponentType.TEXT_DISPLAY)
2188
+ self.content = content
2189
+ self.id = id
2190
+
2191
+ def to_dict(self) -> Dict[str, Any]:
2192
+ payload = super().to_dict()
2193
+ payload["content"] = self.content
2194
+ if self.id is not None:
2195
+ payload["id"] = self.id
2196
+ return payload
2197
+
2198
+
2199
+ class Thumbnail(Component):
2200
+ """Represents a thumbnail component."""
2201
+
2202
+ def __init__(
2203
+ self,
2204
+ media: UnfurledMediaItem,
2205
+ description: Optional[str] = None,
2206
+ spoiler: bool = False,
2207
+ id: Optional[int] = None,
2208
+ ):
2209
+ super().__init__(ComponentType.THUMBNAIL)
2210
+ self.media = media
2211
+ self.description = description
2212
+ self.spoiler = spoiler
2213
+ self.id = id
2214
+
2215
+ def to_dict(self) -> Dict[str, Any]:
2216
+ payload = super().to_dict()
2217
+ payload["media"] = self.media.to_dict()
2218
+ if self.description:
2219
+ payload["description"] = self.description
2220
+ if self.spoiler:
2221
+ payload["spoiler"] = self.spoiler
2222
+ if self.id is not None:
2223
+ payload["id"] = self.id
2224
+ return payload
2225
+
2226
+
2227
+ class Section(Component):
2228
+ """Represents a section component."""
2229
+
2230
+ def __init__(
2231
+ self,
2232
+ components: List[TextDisplay],
2233
+ accessory: Optional[Union[Thumbnail, Button]] = None,
2234
+ id: Optional[int] = None,
2235
+ ):
2236
+ super().__init__(ComponentType.SECTION)
2237
+ self.components = components
2238
+ self.accessory = accessory
2239
+ self.id = id
2240
+
2241
+ def to_dict(self) -> Dict[str, Any]:
2242
+ payload = super().to_dict()
2243
+ payload["components"] = [c.to_dict() for c in self.components]
2244
+ if self.accessory:
2245
+ payload["accessory"] = self.accessory.to_dict()
2246
+ if self.id is not None:
2247
+ payload["id"] = self.id
2248
+ return payload
2249
+
2250
+
2251
+ class MediaGallery(Component):
2252
+ """Represents a media gallery component."""
2253
+
2254
+ def __init__(self, items: List[MediaGalleryItem], id: Optional[int] = None):
2255
+ super().__init__(ComponentType.MEDIA_GALLERY)
2256
+ self.items = items
2257
+ self.id = id
2258
+
2259
+ def to_dict(self) -> Dict[str, Any]:
2260
+ payload = super().to_dict()
2261
+ payload["items"] = [i.to_dict() for i in self.items]
2262
+ if self.id is not None:
2263
+ payload["id"] = self.id
2264
+ return payload
2265
+
2266
+
2267
+ class FileComponent(Component):
2268
+ """Represents a file component."""
2269
+
2270
+ def __init__(
2271
+ self, file: UnfurledMediaItem, spoiler: bool = False, id: Optional[int] = None
2272
+ ):
2273
+ super().__init__(ComponentType.FILE)
2274
+ self.file = file
2275
+ self.spoiler = spoiler
2276
+ self.id = id
2277
+
2278
+ def to_dict(self) -> Dict[str, Any]:
2279
+ payload = super().to_dict()
2280
+ payload["file"] = self.file.to_dict()
2281
+ if self.spoiler:
2282
+ payload["spoiler"] = self.spoiler
2283
+ if self.id is not None:
2284
+ payload["id"] = self.id
2285
+ return payload
2286
+
2287
+
2288
+ class Separator(Component):
2289
+ """Represents a separator component."""
2290
+
2291
+ def __init__(
2292
+ self, divider: bool = True, spacing: int = 1, id: Optional[int] = None
2293
+ ):
2294
+ super().__init__(ComponentType.SEPARATOR)
2295
+ self.divider = divider
2296
+ self.spacing = spacing
2297
+ self.id = id
2298
+
2299
+ def to_dict(self) -> Dict[str, Any]:
2300
+ payload = super().to_dict()
2301
+ payload["divider"] = self.divider
2302
+ payload["spacing"] = self.spacing
2303
+ if self.id is not None:
2304
+ payload["id"] = self.id
2305
+ return payload
2306
+
2307
+
2308
+ class Container(Component):
2309
+ """Represents a container component."""
2310
+
2311
+ def __init__(
2312
+ self,
2313
+ components: List[Component],
2314
+ accent_color: Color | int | str | None = None,
2315
+ spoiler: bool = False,
2316
+ id: Optional[int] = None,
2317
+ ):
2318
+ super().__init__(ComponentType.CONTAINER)
2319
+ self.components = components
2320
+ self.accent_color = Color.parse(accent_color)
2321
+ self.spoiler = spoiler
2322
+ self.id = id
2323
+
2324
+ def to_dict(self) -> Dict[str, Any]:
2325
+ payload = super().to_dict()
2326
+ payload["components"] = [c.to_dict() for c in self.components]
2327
+ if self.accent_color:
2328
+ payload["accent_color"] = self.accent_color.value
2329
+ if self.spoiler:
2330
+ payload["spoiler"] = self.spoiler
2331
+ if self.id is not None:
2332
+ payload["id"] = self.id
2333
+ return payload
2334
+
2335
+
2336
+ class WelcomeChannel:
2337
+ """Represents a channel shown in the server's welcome screen.
2338
+
2339
+ Attributes:
2340
+ channel_id (str): The ID of the channel.
2341
+ description (str): The description shown for the channel.
2342
+ emoji_id (Optional[str]): The ID of the emoji, if custom.
2343
+ emoji_name (Optional[str]): The name of the emoji if custom, or the unicode character if standard.
2344
+ """
2345
+
2346
+ def __init__(self, data: Dict[str, Any]):
2347
+ self.channel_id: str = data["channel_id"]
2348
+ self.description: str = data["description"]
2349
+ self.emoji_id: Optional[str] = data.get("emoji_id")
2350
+ self.emoji_name: Optional[str] = data.get("emoji_name")
2351
+
2352
+ def __repr__(self) -> str:
2353
+ return (
2354
+ f"<WelcomeChannel id='{self.channel_id}' description='{self.description}'>"
2355
+ )
2356
+
2357
+
2358
+ class WelcomeScreen:
2359
+ """Represents the welcome screen of a Community guild.
2360
+
2361
+ Attributes:
2362
+ description (Optional[str]): The server description shown in the welcome screen.
2363
+ welcome_channels (List[WelcomeChannel]): The channels shown in the welcome screen.
2364
+ """
2365
+
2366
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
2367
+ self._client: "Client" = (
2368
+ client_instance # May be useful for fetching channel objects
2369
+ )
2370
+ self.description: Optional[str] = data.get("description")
2371
+ self.welcome_channels: List[WelcomeChannel] = [
2372
+ WelcomeChannel(wc_data) for wc_data in data.get("welcome_channels", [])
2373
+ ]
2374
+
2375
+ def __repr__(self) -> str:
2376
+ return f"<WelcomeScreen description='{self.description}' channels={len(self.welcome_channels)}>"
2377
+
2378
+
2379
+ class ThreadMember:
2380
+ """Represents a member of a thread.
2381
+
2382
+ Attributes:
2383
+ id (Optional[str]): The ID of the thread. Not always present.
2384
+ user_id (Optional[str]): The ID of the user. Not always present.
2385
+ join_timestamp (str): When the user joined the thread (ISO8601 timestamp).
2386
+ flags (int): User-specific flags for thread settings.
2387
+ member (Optional[Member]): The guild member object for this user, if resolved.
2388
+ Only available from GUILD_MEMBERS intent and if fetched.
2389
+ """
2390
+
2391
+ def __init__(
2392
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2393
+ ): # client_instance for member resolution
2394
+ self._client: Optional["Client"] = client_instance
2395
+ self.id: Optional[str] = data.get("id") # Thread ID
2396
+ self.user_id: Optional[str] = data.get("user_id")
2397
+ self.join_timestamp: str = data["join_timestamp"]
2398
+ self.flags: int = data["flags"]
2399
+
2400
+ # The 'member' field in ThreadMember payload is a full guild member object.
2401
+ # This is present in some contexts like when listing thread members.
2402
+ self.member: Optional[Member] = (
2403
+ Member(data["member"], client_instance) if data.get("member") else None
2404
+ )
2405
+
2406
+ # Note: The 'presence' field is not included as it's often unavailable or too dynamic for a simple model.
2407
+
2408
+ def __repr__(self) -> str:
2409
+ return f"<ThreadMember user_id='{self.user_id}' thread_id='{self.id}'>"
2410
+
2411
+
2412
+ class Activity:
2413
+ """Represents a user's presence activity."""
2414
+
2415
+ def __init__(self, name: str, type: int) -> None:
2416
+ self.name = name
2417
+ self.type = type
2418
+
2419
+ def to_dict(self) -> Dict[str, Any]:
2420
+ return {"name": self.name, "type": self.type}
2421
+
2422
+
2423
+ class Game(Activity):
2424
+ """Represents a playing activity."""
2425
+
2426
+ def __init__(self, name: str) -> None:
2427
+ super().__init__(name, 0)
2428
+
2429
+
2430
+ class Streaming(Activity):
2431
+ """Represents a streaming activity."""
2432
+
2433
+ def __init__(self, name: str, url: str) -> None:
2434
+ super().__init__(name, 1)
2435
+ self.url = url
2436
+
2437
+ def to_dict(self) -> Dict[str, Any]:
2438
+ payload = super().to_dict()
2439
+ payload["url"] = self.url
2440
+ return payload
2441
+
2442
+
2443
+ class PresenceUpdate:
2444
+ """Represents a PRESENCE_UPDATE event."""
2445
+
2446
+ def __init__(
2447
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2448
+ ):
2449
+ self._client = client_instance
2450
+ self.user = User(data["user"])
2451
+ self.guild_id: Optional[str] = data.get("guild_id")
2452
+ self.status: Optional[str] = data.get("status")
2453
+ self.activities: List[Activity] = []
2454
+ for activity in data.get("activities", []):
2455
+ act_type = activity.get("type", 0)
2456
+ name = activity.get("name", "")
2457
+ if act_type == 0:
2458
+ obj = Game(name)
2459
+ elif act_type == 1:
2460
+ obj = Streaming(name, activity.get("url", ""))
2461
+ else:
2462
+ obj = Activity(name, act_type)
2463
+ self.activities.append(obj)
2464
+ self.client_status: Dict[str, Any] = data.get("client_status", {})
2465
+
2466
+ def __repr__(self) -> str:
2467
+ return f"<PresenceUpdate user_id='{self.user.id}' guild_id='{self.guild_id}' status='{self.status}'>"
2468
+
2469
+
2470
+ class TypingStart:
2471
+ """Represents a TYPING_START event."""
2472
+
2473
+ def __init__(
2474
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2475
+ ):
2476
+ self._client = client_instance
2477
+ self.channel_id: str = data["channel_id"]
2478
+ self.guild_id: Optional[str] = data.get("guild_id")
2479
+ self.user_id: str = data["user_id"]
2480
+ self.timestamp: int = data["timestamp"]
2481
+ self.member: Optional[Member] = (
2482
+ Member(data["member"], client_instance) if data.get("member") else None
2483
+ )
2484
+
2485
+ def __repr__(self) -> str:
2486
+ return f"<TypingStart channel_id='{self.channel_id}' user_id='{self.user_id}'>"
2487
+
2488
+
2489
+ class Reaction:
2490
+ """Represents a message reaction event."""
2491
+
2492
+ def __init__(
2493
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2494
+ ):
2495
+ self._client = client_instance
2496
+ self.user_id: str = data["user_id"]
2497
+ self.channel_id: str = data["channel_id"]
2498
+ self.message_id: str = data["message_id"]
2499
+ self.guild_id: Optional[str] = data.get("guild_id")
2500
+ self.member: Optional[Member] = (
2501
+ Member(data["member"], client_instance) if data.get("member") else None
2502
+ )
2503
+ self.emoji: Dict[str, Any] = data.get("emoji", {})
2504
+
2505
+ def __repr__(self) -> str:
2506
+ emoji_value = self.emoji.get("name") or self.emoji.get("id")
2507
+ return f"<Reaction message_id='{self.message_id}' user_id='{self.user_id}' emoji='{emoji_value}'>"
2508
+
2509
+
2510
+ class ScheduledEvent:
2511
+ """Represents a guild scheduled event."""
2512
+
2513
+ def __init__(
2514
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2515
+ ):
2516
+ self._client = client_instance
2517
+ self.id: str = data["id"]
2518
+ self.guild_id: str = data["guild_id"]
2519
+ self.channel_id: Optional[str] = data.get("channel_id")
2520
+ self.creator_id: Optional[str] = data.get("creator_id")
2521
+ self.name: str = data["name"]
2522
+ self.description: Optional[str] = data.get("description")
2523
+ self.scheduled_start_time: str = data["scheduled_start_time"]
2524
+ self.scheduled_end_time: Optional[str] = data.get("scheduled_end_time")
2525
+ self.privacy_level: GuildScheduledEventPrivacyLevel = (
2526
+ GuildScheduledEventPrivacyLevel(data["privacy_level"])
2527
+ )
2528
+ self.status: GuildScheduledEventStatus = GuildScheduledEventStatus(
2529
+ data["status"]
2530
+ )
2531
+ self.entity_type: GuildScheduledEventEntityType = GuildScheduledEventEntityType(
2532
+ data["entity_type"]
2533
+ )
2534
+ self.entity_id: Optional[str] = data.get("entity_id")
2535
+ self.entity_metadata: Optional[Dict[str, Any]] = data.get("entity_metadata")
2536
+ self.creator: Optional[User] = (
2537
+ User(data["creator"]) if data.get("creator") else None
2538
+ )
2539
+ self.user_count: Optional[int] = data.get("user_count")
2540
+ self.image: Optional[str] = data.get("image")
2541
+
2542
+ def __repr__(self) -> str:
2543
+ return f"<ScheduledEvent id='{self.id}' name='{self.name}'>"
2544
+
2545
+
2546
+ @dataclass
2547
+ class Invite:
2548
+ """Represents a Discord invite."""
2549
+
2550
+ code: str
2551
+ channel_id: Optional[str]
2552
+ guild_id: Optional[str]
2553
+ inviter_id: Optional[str]
2554
+ uses: Optional[int]
2555
+ max_uses: Optional[int]
2556
+ max_age: Optional[int]
2557
+ temporary: Optional[bool]
2558
+ created_at: Optional[str]
2559
+
2560
+ @classmethod
2561
+ def from_dict(cls, data: Dict[str, Any]) -> "Invite":
2562
+ channel = data.get("channel")
2563
+ guild = data.get("guild")
2564
+ inviter = data.get("inviter")
2565
+ return cls(
2566
+ code=data["code"],
2567
+ channel_id=(channel or {}).get("id") if channel else data.get("channel_id"),
2568
+ guild_id=(guild or {}).get("id") if guild else data.get("guild_id"),
2569
+ inviter_id=(inviter or {}).get("id"),
2570
+ uses=data.get("uses"),
2571
+ max_uses=data.get("max_uses"),
2572
+ max_age=data.get("max_age"),
2573
+ temporary=data.get("temporary"),
2574
+ created_at=data.get("created_at"),
2575
+ )
2576
+
2577
+ def __repr__(self) -> str:
2578
+ return f"<Invite code='{self.code}' guild_id='{self.guild_id}' channel_id='{self.channel_id}'>"
2579
+
2580
+
2581
+ class GuildMemberRemove:
2582
+ """Represents a GUILD_MEMBER_REMOVE event."""
2583
+
2584
+ def __init__(
2585
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2586
+ ):
2587
+ self._client = client_instance
2588
+ self.guild_id: str = data["guild_id"]
2589
+ self.user: User = User(data["user"])
2590
+
2591
+ def __repr__(self) -> str:
2592
+ return (
2593
+ f"<GuildMemberRemove guild_id='{self.guild_id}' user_id='{self.user.id}'>"
2594
+ )
2595
+
2596
+
2597
+ class GuildBanAdd:
2598
+ """Represents a GUILD_BAN_ADD event."""
2599
+
2600
+ def __init__(
2601
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2602
+ ):
2603
+ self._client = client_instance
2604
+ self.guild_id: str = data["guild_id"]
2605
+ self.user: User = User(data["user"])
2606
+
2607
+ def __repr__(self) -> str:
2608
+ return f"<GuildBanAdd guild_id='{self.guild_id}' user_id='{self.user.id}'>"
2609
+
2610
+
2611
+ class GuildBanRemove:
2612
+ """Represents a GUILD_BAN_REMOVE event."""
2613
+
2614
+ def __init__(
2615
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2616
+ ):
2617
+ self._client = client_instance
2618
+ self.guild_id: str = data["guild_id"]
2619
+ self.user: User = User(data["user"])
2620
+
2621
+ def __repr__(self) -> str:
2622
+ return f"<GuildBanRemove guild_id='{self.guild_id}' user_id='{self.user.id}'>"
2623
+
2624
+
2625
+ class GuildRoleUpdate:
2626
+ """Represents a GUILD_ROLE_UPDATE event."""
2627
+
2628
+ def __init__(
2629
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2630
+ ):
2631
+ self._client = client_instance
2632
+ self.guild_id: str = data["guild_id"]
2633
+ self.role: Role = Role(data["role"])
2634
+
2635
+ def __repr__(self) -> str:
2636
+ return f"<GuildRoleUpdate guild_id='{self.guild_id}' role_id='{self.role.id}'>"
2637
+
2638
+
2639
+ class AuditLogEntry:
2640
+ """Represents a single entry in a guild's audit log."""
2641
+
2642
+ def __init__(
2643
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
2644
+ ) -> None:
2645
+ self._client = client_instance
2646
+ self.id: str = data["id"]
2647
+ self.user_id: Optional[str] = data.get("user_id")
2648
+ self.target_id: Optional[str] = data.get("target_id")
2649
+ self.action_type: int = data["action_type"]
2650
+ self.reason: Optional[str] = data.get("reason")
2651
+ self.changes: List[Dict[str, Any]] = data.get("changes", [])
2652
+ self.options: Optional[Dict[str, Any]] = data.get("options")
2653
+
2654
+ def __repr__(self) -> str:
2655
+ return f"<AuditLogEntry id='{self.id}' action_type={self.action_type} user_id='{self.user_id}'>"
2656
+
2657
+
2658
+ def channel_factory(data: Dict[str, Any], client: "Client") -> Channel:
2659
+ """Create a channel object from raw API data."""
2660
+ channel_type = data.get("type")
2661
+
2662
+ if channel_type in (
2663
+ ChannelType.GUILD_TEXT.value,
2664
+ ChannelType.GUILD_ANNOUNCEMENT.value,
2665
+ ):
2666
+ return TextChannel(data, client)
2667
+ if channel_type == ChannelType.GUILD_VOICE.value:
2668
+ return VoiceChannel(data, client)
2669
+ if channel_type == ChannelType.GUILD_STAGE_VOICE.value:
2670
+ return StageChannel(data, client)
2671
+ if channel_type == ChannelType.GUILD_CATEGORY.value:
2672
+ return CategoryChannel(data, client)
2673
+ if channel_type in (
2674
+ ChannelType.ANNOUNCEMENT_THREAD.value,
2675
+ ChannelType.PUBLIC_THREAD.value,
2676
+ ChannelType.PRIVATE_THREAD.value,
2677
+ ):
2678
+ return Thread(data, client)
2679
+ if channel_type in (ChannelType.DM.value, ChannelType.GROUP_DM.value):
2680
+ return DMChannel(data, client)
2681
+
2682
+ return Channel(data, client)