disagreement 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
disagreement/models.py ADDED
@@ -0,0 +1,1642 @@
1
+ # disagreement/models.py
2
+
3
+ """
4
+ Data models for Discord objects.
5
+ """
6
+
7
+ import json
8
+ from typing import Optional, TYPE_CHECKING, List, Dict, Any, Union
9
+
10
+ from .errors import DisagreementException, HTTPException
11
+ from .enums import ( # These enums will need to be defined in disagreement/enums.py
12
+ VerificationLevel,
13
+ MessageNotificationLevel,
14
+ ExplicitContentFilterLevel,
15
+ MFALevel,
16
+ GuildNSFWLevel,
17
+ PremiumTier,
18
+ GuildFeature,
19
+ ChannelType,
20
+ ComponentType,
21
+ ButtonStyle, # Added for Button
22
+ # SelectMenuType will be part of ComponentType or a new enum if needed
23
+ )
24
+
25
+
26
+ if TYPE_CHECKING:
27
+ from .client import Client # For type hinting to avoid circular imports
28
+ from .enums import OverwriteType # For PermissionOverwrite model
29
+ from .ui.view import View
30
+
31
+ # Forward reference Message if it were used in type hints before its definition
32
+ # from .models import Message # Not needed as Message is defined before its use in TextChannel.send etc.
33
+ from .components import component_factory
34
+
35
+
36
+ class User:
37
+ """Represents a Discord User.
38
+
39
+ Attributes:
40
+ id (str): The user's unique ID.
41
+ username (str): The user's username.
42
+ discriminator (str): The user's 4-digit discord-tag.
43
+ bot (bool): Whether the user belongs to an OAuth2 application. Defaults to False.
44
+ avatar (Optional[str]): The user's avatar hash, if any.
45
+ """
46
+
47
+ def __init__(self, data: dict):
48
+ self.id: str = data["id"]
49
+ self.username: str = data["username"]
50
+ self.discriminator: str = data["discriminator"]
51
+ self.bot: bool = data.get("bot", False)
52
+ self.avatar: Optional[str] = data.get("avatar")
53
+
54
+ @property
55
+ def mention(self) -> str:
56
+ """str: Returns a string that allows you to mention the user."""
57
+ return f"<@{self.id}>"
58
+
59
+ def __repr__(self) -> str:
60
+ return f"<User id='{self.id}' username='{self.username}' discriminator='{self.discriminator}'>"
61
+
62
+
63
+ class Message:
64
+ """Represents a message sent in a channel on Discord.
65
+
66
+ Attributes:
67
+ id (str): The message's unique ID.
68
+ channel_id (str): The ID of the channel the message was sent in.
69
+ guild_id (Optional[str]): The ID of the guild the message was sent in, if applicable.
70
+ author (User): The user who sent the message.
71
+ content (str): The actual content of the message.
72
+ timestamp (str): When this message was sent (ISO8601 timestamp).
73
+ components (Optional[List[ActionRow]]): Structured components attached
74
+ to the message if present.
75
+ """
76
+
77
+ def __init__(self, data: dict, client_instance: "Client"):
78
+ self._client: "Client" = (
79
+ client_instance # Store reference to client for methods like reply
80
+ )
81
+
82
+ self.id: str = data["id"]
83
+ self.channel_id: str = data["channel_id"]
84
+ self.guild_id: Optional[str] = data.get("guild_id")
85
+ self.author: User = User(data["author"])
86
+ self.content: str = data["content"]
87
+ self.timestamp: str = data["timestamp"]
88
+ if data.get("components"):
89
+ self.components: Optional[List[ActionRow]] = [
90
+ ActionRow.from_dict(c, client_instance)
91
+ for c in data.get("components", [])
92
+ ]
93
+ else:
94
+ self.components = None
95
+ # Add other fields as needed, e.g., attachments, embeds, reactions, etc.
96
+ # self.mentions: List[User] = [User(u) for u in data.get("mentions", [])]
97
+ # self.mention_roles: List[str] = data.get("mention_roles", [])
98
+ # self.mention_everyone: bool = data.get("mention_everyone", False)
99
+
100
+ async def reply(
101
+ self,
102
+ content: Optional[str] = None,
103
+ *, # Make additional params keyword-only
104
+ tts: bool = False,
105
+ embed: Optional["Embed"] = None,
106
+ embeds: Optional[List["Embed"]] = None,
107
+ components: Optional[List["ActionRow"]] = None,
108
+ allowed_mentions: Optional[Dict[str, Any]] = None,
109
+ mention_author: Optional[bool] = None,
110
+ flags: Optional[int] = None,
111
+ view: Optional["View"] = None,
112
+ ) -> "Message":
113
+ """|coro|
114
+
115
+ Sends a reply to the message.
116
+ This is a shorthand for `Client.send_message` in the message's channel.
117
+
118
+ Parameters:
119
+ content (Optional[str]): The content of the message.
120
+ tts (bool): Whether the message should be sent with text-to-speech.
121
+ embed (Optional[Embed]): A single embed to send. Cannot be used with `embeds`.
122
+ embeds (Optional[List[Embed]]): A list of embeds to send.
123
+ components (Optional[List[ActionRow]]): A list of ActionRow components.
124
+ allowed_mentions (Optional[Dict[str, Any]]): Allowed mentions for the message.
125
+ mention_author (Optional[bool]): Whether to mention the author in the reply. If ``None`` the
126
+ client's :attr:`mention_replies` setting is used.
127
+ flags (Optional[int]): Message flags.
128
+ view (Optional[View]): A view to send with the message.
129
+
130
+ Returns:
131
+ Message: The message that was sent.
132
+
133
+ Raises:
134
+ HTTPException: Sending the message failed.
135
+ ValueError: If both `embed` and `embeds` are provided.
136
+ """
137
+ # Determine allowed mentions for the reply
138
+ if mention_author is None:
139
+ mention_author = getattr(self._client, "mention_replies", False)
140
+
141
+ if allowed_mentions is None:
142
+ allowed_mentions = {"replied_user": mention_author}
143
+ else:
144
+ allowed_mentions = dict(allowed_mentions)
145
+ allowed_mentions.setdefault("replied_user", mention_author)
146
+
147
+ # Client.send_message is already updated to handle these parameters
148
+ return await self._client.send_message(
149
+ channel_id=self.channel_id,
150
+ content=content,
151
+ tts=tts,
152
+ embed=embed,
153
+ embeds=embeds,
154
+ components=components,
155
+ allowed_mentions=allowed_mentions,
156
+ message_reference={
157
+ "message_id": self.id,
158
+ "channel_id": self.channel_id,
159
+ "guild_id": self.guild_id,
160
+ },
161
+ flags=flags,
162
+ view=view,
163
+ )
164
+
165
+ async def edit(
166
+ self,
167
+ *,
168
+ content: Optional[str] = None,
169
+ embed: Optional["Embed"] = None,
170
+ embeds: Optional[List["Embed"]] = None,
171
+ components: Optional[List["ActionRow"]] = None,
172
+ allowed_mentions: Optional[Dict[str, Any]] = None,
173
+ flags: Optional[int] = None,
174
+ view: Optional["View"] = None,
175
+ ) -> "Message":
176
+ """|coro|
177
+
178
+ Edits this message.
179
+
180
+ Parameters are the same as :meth:`Client.edit_message`.
181
+ """
182
+
183
+ return await self._client.edit_message(
184
+ channel_id=self.channel_id,
185
+ message_id=self.id,
186
+ content=content,
187
+ embed=embed,
188
+ embeds=embeds,
189
+ components=components,
190
+ allowed_mentions=allowed_mentions,
191
+ flags=flags,
192
+ view=view,
193
+ )
194
+
195
+ def __repr__(self) -> str:
196
+ return f"<Message id='{self.id}' channel_id='{self.channel_id}' author='{self.author!r}'>"
197
+
198
+
199
+ class EmbedFooter:
200
+ """Represents an embed footer."""
201
+
202
+ def __init__(self, data: Dict[str, Any]):
203
+ self.text: str = data["text"]
204
+ self.icon_url: Optional[str] = data.get("icon_url")
205
+ self.proxy_icon_url: Optional[str] = data.get("proxy_icon_url")
206
+
207
+ def to_dict(self) -> Dict[str, Any]:
208
+ payload = {"text": self.text}
209
+ if self.icon_url:
210
+ payload["icon_url"] = self.icon_url
211
+ if self.proxy_icon_url:
212
+ payload["proxy_icon_url"] = self.proxy_icon_url
213
+ return payload
214
+
215
+
216
+ class EmbedImage:
217
+ """Represents an embed image."""
218
+
219
+ def __init__(self, data: Dict[str, Any]):
220
+ self.url: str = data["url"]
221
+ self.proxy_url: Optional[str] = data.get("proxy_url")
222
+ self.height: Optional[int] = data.get("height")
223
+ self.width: Optional[int] = data.get("width")
224
+
225
+ def to_dict(self) -> Dict[str, Any]:
226
+ payload: Dict[str, Any] = {"url": self.url}
227
+ if self.proxy_url:
228
+ payload["proxy_url"] = self.proxy_url
229
+ if self.height:
230
+ payload["height"] = self.height
231
+ if self.width:
232
+ payload["width"] = self.width
233
+ return payload
234
+
235
+ def __repr__(self) -> str:
236
+ return f"<EmbedImage url='{self.url}'>"
237
+
238
+
239
+ class EmbedThumbnail(EmbedImage): # Similar structure to EmbedImage
240
+ """Represents an embed thumbnail."""
241
+
242
+ pass
243
+
244
+
245
+ class EmbedAuthor:
246
+ """Represents an embed author."""
247
+
248
+ def __init__(self, data: Dict[str, Any]):
249
+ self.name: str = data["name"]
250
+ self.url: Optional[str] = data.get("url")
251
+ self.icon_url: Optional[str] = data.get("icon_url")
252
+ self.proxy_icon_url: Optional[str] = data.get("proxy_icon_url")
253
+
254
+ def to_dict(self) -> Dict[str, Any]:
255
+ payload = {"name": self.name}
256
+ if self.url:
257
+ payload["url"] = self.url
258
+ if self.icon_url:
259
+ payload["icon_url"] = self.icon_url
260
+ if self.proxy_icon_url:
261
+ payload["proxy_icon_url"] = self.proxy_icon_url
262
+ return payload
263
+
264
+
265
+ class EmbedField:
266
+ """Represents an embed field."""
267
+
268
+ def __init__(self, data: Dict[str, Any]):
269
+ self.name: str = data["name"]
270
+ self.value: str = data["value"]
271
+ self.inline: bool = data.get("inline", False)
272
+
273
+ def to_dict(self) -> Dict[str, Any]:
274
+ return {"name": self.name, "value": self.value, "inline": self.inline}
275
+
276
+
277
+ class Embed:
278
+ """Represents a Discord embed.
279
+
280
+ Attributes can be set directly or via methods like `set_author`, `add_field`.
281
+ """
282
+
283
+ def __init__(self, data: Optional[Dict[str, Any]] = None):
284
+ data = data or {}
285
+ self.title: Optional[str] = data.get("title")
286
+ self.type: str = data.get("type", "rich") # Default to "rich" for sending
287
+ self.description: Optional[str] = data.get("description")
288
+ self.url: Optional[str] = data.get("url")
289
+ self.timestamp: Optional[str] = data.get("timestamp") # ISO8601 timestamp
290
+ self.color: Optional[int] = data.get("color")
291
+
292
+ self.footer: Optional[EmbedFooter] = (
293
+ EmbedFooter(data["footer"]) if data.get("footer") else None
294
+ )
295
+ self.image: Optional[EmbedImage] = (
296
+ EmbedImage(data["image"]) if data.get("image") else None
297
+ )
298
+ self.thumbnail: Optional[EmbedThumbnail] = (
299
+ EmbedThumbnail(data["thumbnail"]) if data.get("thumbnail") else None
300
+ )
301
+ # Video and Provider are less common for bot-sent embeds, can be added if needed.
302
+ self.author: Optional[EmbedAuthor] = (
303
+ EmbedAuthor(data["author"]) if data.get("author") else None
304
+ )
305
+ self.fields: List[EmbedField] = (
306
+ [EmbedField(f) for f in data["fields"]] if data.get("fields") else []
307
+ )
308
+
309
+ def to_dict(self) -> Dict[str, Any]:
310
+ payload: Dict[str, Any] = {"type": self.type}
311
+ if self.title:
312
+ payload["title"] = self.title
313
+ if self.description:
314
+ payload["description"] = self.description
315
+ if self.url:
316
+ payload["url"] = self.url
317
+ if self.timestamp:
318
+ payload["timestamp"] = self.timestamp
319
+ if self.color is not None:
320
+ payload["color"] = self.color
321
+ if self.footer:
322
+ payload["footer"] = self.footer.to_dict()
323
+ if self.image:
324
+ payload["image"] = self.image.to_dict()
325
+ if self.thumbnail:
326
+ payload["thumbnail"] = self.thumbnail.to_dict()
327
+ if self.author:
328
+ payload["author"] = self.author.to_dict()
329
+ if self.fields:
330
+ payload["fields"] = [f.to_dict() for f in self.fields]
331
+ return payload
332
+
333
+ # Convenience methods for building embeds can be added here
334
+ # e.g., set_author, add_field, set_footer, set_image, etc.
335
+
336
+
337
+ class Attachment:
338
+ """Represents a message attachment."""
339
+
340
+ def __init__(self, data: Dict[str, Any]):
341
+ self.id: str = data["id"]
342
+ self.filename: str = data["filename"]
343
+ self.description: Optional[str] = data.get("description")
344
+ self.content_type: Optional[str] = data.get("content_type")
345
+ self.size: Optional[int] = data.get("size")
346
+ self.url: Optional[str] = data.get("url")
347
+ self.proxy_url: Optional[str] = data.get("proxy_url")
348
+ self.height: Optional[int] = data.get("height") # If image
349
+ self.width: Optional[int] = data.get("width") # If image
350
+ self.ephemeral: bool = data.get("ephemeral", False)
351
+
352
+ def __repr__(self) -> str:
353
+ return f"<Attachment id='{self.id}' filename='{self.filename}'>"
354
+
355
+ def to_dict(self) -> Dict[str, Any]:
356
+ payload: Dict[str, Any] = {"id": self.id, "filename": self.filename}
357
+ if self.description is not None:
358
+ payload["description"] = self.description
359
+ if self.content_type is not None:
360
+ payload["content_type"] = self.content_type
361
+ if self.size is not None:
362
+ payload["size"] = self.size
363
+ if self.url is not None:
364
+ payload["url"] = self.url
365
+ if self.proxy_url is not None:
366
+ payload["proxy_url"] = self.proxy_url
367
+ if self.height is not None:
368
+ payload["height"] = self.height
369
+ if self.width is not None:
370
+ payload["width"] = self.width
371
+ if self.ephemeral:
372
+ payload["ephemeral"] = self.ephemeral
373
+ return payload
374
+
375
+
376
+ class AllowedMentions:
377
+ """Represents allowed mentions for a message or interaction response."""
378
+
379
+ def __init__(self, data: Dict[str, Any]):
380
+ self.parse: List[str] = data.get("parse", [])
381
+ self.roles: List[str] = data.get("roles", [])
382
+ self.users: List[str] = data.get("users", [])
383
+ self.replied_user: bool = data.get("replied_user", False)
384
+
385
+ def to_dict(self) -> Dict[str, Any]:
386
+ payload: Dict[str, Any] = {"parse": self.parse}
387
+ if self.roles:
388
+ payload["roles"] = self.roles
389
+ if self.users:
390
+ payload["users"] = self.users
391
+ if self.replied_user:
392
+ payload["replied_user"] = self.replied_user
393
+ return payload
394
+
395
+
396
+ class RoleTags:
397
+ """Represents tags for a role."""
398
+
399
+ def __init__(self, data: Dict[str, Any]):
400
+ self.bot_id: Optional[str] = data.get("bot_id")
401
+ self.integration_id: Optional[str] = data.get("integration_id")
402
+ self.premium_subscriber: Optional[bool] = (
403
+ data.get("premium_subscriber") is None
404
+ ) # presence of null value means true
405
+
406
+ def to_dict(self) -> Dict[str, Any]:
407
+ payload = {}
408
+ if self.bot_id:
409
+ payload["bot_id"] = self.bot_id
410
+ if self.integration_id:
411
+ payload["integration_id"] = self.integration_id
412
+ if self.premium_subscriber:
413
+ payload["premium_subscriber"] = None # Explicitly null
414
+ return payload
415
+
416
+
417
+ class Role:
418
+ """Represents a Discord Role."""
419
+
420
+ def __init__(self, data: Dict[str, Any]):
421
+ self.id: str = data["id"]
422
+ self.name: str = data["name"]
423
+ self.color: int = data["color"]
424
+ self.hoist: bool = data["hoist"]
425
+ self.icon: Optional[str] = data.get("icon")
426
+ self.unicode_emoji: Optional[str] = data.get("unicode_emoji")
427
+ self.position: int = data["position"]
428
+ self.permissions: str = data["permissions"] # String of bitwise permissions
429
+ self.managed: bool = data["managed"]
430
+ self.mentionable: bool = data["mentionable"]
431
+ self.tags: Optional[RoleTags] = (
432
+ RoleTags(data["tags"]) if data.get("tags") else None
433
+ )
434
+
435
+ @property
436
+ def mention(self) -> str:
437
+ """str: Returns a string that allows you to mention the role."""
438
+ return f"<@&{self.id}>"
439
+
440
+ def __repr__(self) -> str:
441
+ return f"<Role id='{self.id}' name='{self.name}'>"
442
+
443
+
444
+ class Member(User): # Member inherits from User
445
+ """Represents a Guild Member.
446
+ This class combines User attributes with guild-specific Member attributes.
447
+ """
448
+
449
+ def __init__(
450
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
451
+ ):
452
+ self._client: Optional["Client"] = client_instance
453
+ self.guild_id: Optional[str] = None
454
+ # User part is nested under 'user' key in member data from gateway/API
455
+ user_data = data.get("user", {})
456
+ # If 'id' is not in user_data but is top-level (e.g. from interaction resolved member without user object)
457
+ if "id" not in user_data and "id" in data:
458
+ # This case is less common for full member objects but can happen.
459
+ # We'd need to construct a partial user from top-level member fields if 'user' is missing.
460
+ # For now, assume 'user' object is present for full Member hydration.
461
+ # If 'user' is missing, the User part might be incomplete.
462
+ pass # User fields will be missing or default if 'user' not in data.
463
+
464
+ super().__init__(
465
+ user_data if user_data else data
466
+ ) # Pass user_data or data if user_data is empty
467
+
468
+ self.nick: Optional[str] = data.get("nick")
469
+ self.avatar: Optional[str] = data.get("avatar") # Guild-specific avatar hash
470
+ self.roles: List[str] = data.get("roles", []) # List of role IDs
471
+ self.joined_at: str = data["joined_at"] # ISO8601 timestamp
472
+ self.premium_since: Optional[str] = data.get(
473
+ "premium_since"
474
+ ) # ISO8601 timestamp
475
+ self.deaf: bool = data.get("deaf", False)
476
+ self.mute: bool = data.get("mute", False)
477
+ self.pending: bool = data.get("pending", False)
478
+ self.permissions: Optional[str] = data.get(
479
+ "permissions"
480
+ ) # Permissions in the channel, if applicable
481
+ self.communication_disabled_until: Optional[str] = data.get(
482
+ "communication_disabled_until"
483
+ ) # ISO8601 timestamp
484
+
485
+ # If 'user' object was present, ensure User attributes are from there
486
+ if user_data:
487
+ self.id = user_data.get("id", self.id) # Prefer user.id if available
488
+ self.username = user_data.get("username", self.username)
489
+ self.discriminator = user_data.get("discriminator", self.discriminator)
490
+ self.bot = user_data.get("bot", self.bot)
491
+ # User's global avatar is User.avatar, Member.avatar is guild-specific
492
+ # super() already set self.avatar from user_data if present.
493
+ # The self.avatar = data.get("avatar") line above overwrites it with guild avatar. This is correct.
494
+
495
+ def __repr__(self) -> str:
496
+ return f"<Member id='{self.id}' username='{self.username}' nick='{self.nick}'>"
497
+
498
+ async def kick(self, *, reason: Optional[str] = None) -> None:
499
+ if not self.guild_id or not self._client:
500
+ raise DisagreementException("Member.kick requires guild_id and client")
501
+ await self._client._http.kick_member(self.guild_id, self.id, reason=reason)
502
+
503
+ async def ban(
504
+ self,
505
+ *,
506
+ delete_message_seconds: int = 0,
507
+ reason: Optional[str] = None,
508
+ ) -> None:
509
+ if not self.guild_id or not self._client:
510
+ raise DisagreementException("Member.ban requires guild_id and client")
511
+ await self._client._http.ban_member(
512
+ self.guild_id,
513
+ self.id,
514
+ delete_message_seconds=delete_message_seconds,
515
+ reason=reason,
516
+ )
517
+
518
+ async def timeout(
519
+ self, until: Optional[str], *, reason: Optional[str] = None
520
+ ) -> None:
521
+ if not self.guild_id or not self._client:
522
+ raise DisagreementException("Member.timeout requires guild_id and client")
523
+ await self._client._http.timeout_member(
524
+ self.guild_id,
525
+ self.id,
526
+ until=until,
527
+ reason=reason,
528
+ )
529
+
530
+
531
+ class PartialEmoji:
532
+ """Represents a partial emoji, often used in components or reactions.
533
+
534
+ This typically means only id, name, and animated are known.
535
+ For unicode emojis, id will be None and name will be the unicode character.
536
+ """
537
+
538
+ def __init__(self, data: Dict[str, Any]):
539
+ self.id: Optional[str] = data.get("id")
540
+ self.name: Optional[str] = data.get(
541
+ "name"
542
+ ) # Can be None for unknown custom emoji, or unicode char
543
+ self.animated: bool = data.get("animated", False)
544
+
545
+ def to_dict(self) -> Dict[str, Any]:
546
+ payload: Dict[str, Any] = {}
547
+ if self.id:
548
+ payload["id"] = self.id
549
+ if self.name:
550
+ payload["name"] = self.name
551
+ if self.animated: # Only include if true, as per some Discord patterns
552
+ payload["animated"] = self.animated
553
+ return payload
554
+
555
+ def __str__(self) -> str:
556
+ if self.id:
557
+ return f"<{'a' if self.animated else ''}:{self.name}:{self.id}>"
558
+ return self.name or "" # For unicode emoji
559
+
560
+ def __repr__(self) -> str:
561
+ return (
562
+ f"<PartialEmoji id='{self.id}' name='{self.name}' animated={self.animated}>"
563
+ )
564
+
565
+
566
+ def to_partial_emoji(
567
+ value: Union[str, "PartialEmoji", None],
568
+ ) -> Optional["PartialEmoji"]:
569
+ """Convert a string or PartialEmoji to a PartialEmoji instance.
570
+
571
+ Args:
572
+ value: Either a unicode emoji string, a :class:`PartialEmoji`, or ``None``.
573
+
574
+ Returns:
575
+ A :class:`PartialEmoji` or ``None`` if ``value`` was ``None``.
576
+
577
+ Raises:
578
+ TypeError: If ``value`` is not ``str`` or :class:`PartialEmoji`.
579
+ """
580
+
581
+ if value is None or isinstance(value, PartialEmoji):
582
+ return value
583
+ if isinstance(value, str):
584
+ return PartialEmoji({"name": value, "id": None})
585
+ raise TypeError("emoji must be a str or PartialEmoji")
586
+
587
+
588
+ class Emoji(PartialEmoji):
589
+ """Represents a custom guild emoji.
590
+
591
+ Inherits id, name, animated from PartialEmoji.
592
+ """
593
+
594
+ def __init__(
595
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
596
+ ):
597
+ super().__init__(data)
598
+ self._client: Optional["Client"] = (
599
+ client_instance # For potential future methods
600
+ )
601
+
602
+ # Roles this emoji is whitelisted to
603
+ self.roles: List[str] = data.get("roles", []) # List of role IDs
604
+
605
+ # User object for the user that created this emoji (optional, only for GUILD_EMOJIS_AND_STICKERS intent)
606
+ self.user: Optional[User] = User(data["user"]) if data.get("user") else None
607
+
608
+ self.require_colons: bool = data.get("require_colons", False)
609
+ self.managed: bool = data.get(
610
+ "managed", False
611
+ ) # If this emoji is managed by an integration
612
+ self.available: bool = data.get(
613
+ "available", True
614
+ ) # Whether this emoji can be used
615
+
616
+ def __repr__(self) -> str:
617
+ return f"<Emoji id='{self.id}' name='{self.name}' animated={self.animated} available={self.available}>"
618
+
619
+
620
+ class StickerItem:
621
+ """Represents a sticker item, a basic representation of a sticker.
622
+
623
+ Used in sticker packs and sometimes in message data.
624
+ """
625
+
626
+ def __init__(self, data: Dict[str, Any]):
627
+ self.id: str = data["id"]
628
+ self.name: str = data["name"]
629
+ self.format_type: int = data["format_type"] # StickerFormatType enum
630
+
631
+ def __repr__(self) -> str:
632
+ return f"<StickerItem id='{self.id}' name='{self.name}'>"
633
+
634
+
635
+ class Sticker(StickerItem):
636
+ """Represents a Discord sticker.
637
+
638
+ Inherits id, name, format_type from StickerItem.
639
+ """
640
+
641
+ def __init__(
642
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
643
+ ):
644
+ super().__init__(data)
645
+ self._client: Optional["Client"] = client_instance
646
+
647
+ self.pack_id: Optional[str] = data.get(
648
+ "pack_id"
649
+ ) # For standard stickers, ID of the pack
650
+ self.description: Optional[str] = data.get("description")
651
+ self.tags: str = data.get(
652
+ "tags", ""
653
+ ) # Comma-separated list of tags for guild stickers
654
+ # type is StickerType enum (STANDARD or GUILD)
655
+ # For guild stickers, this is 2. For standard stickers, this is 1.
656
+ self.type: int = data["type"]
657
+ self.available: bool = data.get(
658
+ "available", True
659
+ ) # Whether this sticker can be used
660
+ self.guild_id: Optional[str] = data.get(
661
+ "guild_id"
662
+ ) # ID of the guild that owns this sticker
663
+
664
+ # User object of the user that uploaded the guild sticker
665
+ self.user: Optional[User] = User(data["user"]) if data.get("user") else None
666
+
667
+ self.sort_value: Optional[int] = data.get(
668
+ "sort_value"
669
+ ) # The standard sticker's sort order within its pack
670
+
671
+ def __repr__(self) -> str:
672
+ return f"<Sticker id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
673
+
674
+
675
+ class StickerPack:
676
+ """Represents a pack of standard stickers."""
677
+
678
+ def __init__(
679
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
680
+ ):
681
+ self._client: Optional["Client"] = client_instance
682
+ self.id: str = data["id"]
683
+ self.stickers: List[Sticker] = [
684
+ Sticker(s_data, client_instance) for s_data in data.get("stickers", [])
685
+ ]
686
+ self.name: str = data["name"]
687
+ self.sku_id: str = data["sku_id"]
688
+ self.cover_sticker_id: Optional[str] = data.get("cover_sticker_id")
689
+ self.description: str = data["description"]
690
+ self.banner_asset_id: Optional[str] = data.get(
691
+ "banner_asset_id"
692
+ ) # ID of the pack's banner image
693
+
694
+ def __repr__(self) -> str:
695
+ return f"<StickerPack id='{self.id}' name='{self.name}' stickers={len(self.stickers)}>"
696
+
697
+
698
+ class PermissionOverwrite:
699
+ """Represents a permission overwrite for a role or member in a channel."""
700
+
701
+ def __init__(self, data: Dict[str, Any]):
702
+ self.id: str = data["id"] # Role or user ID
703
+ self._type_val: int = int(data["type"]) # Store raw type for enum property
704
+ self.allow: str = data["allow"] # Bitwise value of allowed permissions
705
+ self.deny: str = data["deny"] # Bitwise value of denied permissions
706
+
707
+ @property
708
+ def type(self) -> "OverwriteType":
709
+ from .enums import (
710
+ OverwriteType,
711
+ ) # Local import to avoid circularity at module level
712
+
713
+ return OverwriteType(self._type_val)
714
+
715
+ def to_dict(self) -> Dict[str, Any]:
716
+ return {
717
+ "id": self.id,
718
+ "type": self.type.value,
719
+ "allow": self.allow,
720
+ "deny": self.deny,
721
+ }
722
+
723
+ def __repr__(self) -> str:
724
+ 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}'>"
725
+
726
+
727
+ class Guild:
728
+ """Represents a Discord Guild (Server).
729
+
730
+ Attributes:
731
+ id (str): Guild ID.
732
+ name (str): Guild name (2-100 characters, excluding @, #, :, ```).
733
+ icon (Optional[str]): Icon hash.
734
+ splash (Optional[str]): Splash hash.
735
+ discovery_splash (Optional[str]): Discovery splash hash; only present for discoverable guilds.
736
+ owner (Optional[bool]): True if the user is the owner of the guild. (Only for /users/@me/guilds endpoint)
737
+ owner_id (str): ID of owner.
738
+ permissions (Optional[str]): Total permissions for the user in the guild (excludes overwrites). (Only for /users/@me/guilds endpoint)
739
+ afk_channel_id (Optional[str]): ID of afk channel.
740
+ afk_timeout (int): AFK timeout in seconds.
741
+ widget_enabled (Optional[bool]): True if the server widget is enabled.
742
+ widget_channel_id (Optional[str]): The channel id that the widget will generate an invite to, or null if set to no invite.
743
+ verification_level (VerificationLevel): Verification level required for the guild.
744
+ default_message_notifications (MessageNotificationLevel): Default message notifications level.
745
+ explicit_content_filter (ExplicitContentFilterLevel): Explicit content filter level.
746
+ roles (List[Role]): Roles in the guild.
747
+ emojis (List[Dict]): Custom emojis. (Consider creating an Emoji model)
748
+ features (List[GuildFeature]): Enabled guild features.
749
+ mfa_level (MFALevel): Required MFA level for the guild.
750
+ application_id (Optional[str]): Application ID of the guild creator if it is bot-created.
751
+ system_channel_id (Optional[str]): The id of the channel where guild notices such as welcome messages and boost events are posted.
752
+ system_channel_flags (int): System channel flags.
753
+ rules_channel_id (Optional[str]): The id of the channel where Community guilds can display rules.
754
+ max_members (Optional[int]): The maximum number of members for the guild.
755
+ vanity_url_code (Optional[str]): The vanity url code for the guild.
756
+ description (Optional[str]): The description of a Community guild.
757
+ banner (Optional[str]): Banner hash.
758
+ premium_tier (PremiumTier): Premium tier (Server Boost level).
759
+ premium_subscription_count (Optional[int]): The number of boosts this guild currently has.
760
+ preferred_locale (str): The preferred locale of a Community guild. Defaults to "en-US".
761
+ public_updates_channel_id (Optional[str]): The id of the channel where admins and moderators of Community guilds receive notices from Discord.
762
+ max_video_channel_users (Optional[int]): The maximum number of users in a video channel.
763
+ welcome_screen (Optional[Dict]): The welcome screen of a Community guild. (Consider a WelcomeScreen model)
764
+ nsfw_level (GuildNSFWLevel): Guild NSFW level.
765
+ stickers (Optional[List[Dict]]): Custom stickers in the guild. (Consider a Sticker model)
766
+ premium_progress_bar_enabled (bool): Whether the guild has the premium progress bar enabled.
767
+ """
768
+
769
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
770
+ self._client: "Client" = client_instance
771
+ self.id: str = data["id"]
772
+ self.name: str = data["name"]
773
+ self.icon: Optional[str] = data.get("icon")
774
+ self.splash: Optional[str] = data.get("splash")
775
+ self.discovery_splash: Optional[str] = data.get("discovery_splash")
776
+ self.owner: Optional[bool] = data.get("owner")
777
+ self.owner_id: str = data["owner_id"]
778
+ self.permissions: Optional[str] = data.get("permissions")
779
+ self.afk_channel_id: Optional[str] = data.get("afk_channel_id")
780
+ self.afk_timeout: int = data["afk_timeout"]
781
+ self.widget_enabled: Optional[bool] = data.get("widget_enabled")
782
+ self.widget_channel_id: Optional[str] = data.get("widget_channel_id")
783
+ self.verification_level: VerificationLevel = VerificationLevel(
784
+ data["verification_level"]
785
+ )
786
+ self.default_message_notifications: MessageNotificationLevel = (
787
+ MessageNotificationLevel(data["default_message_notifications"])
788
+ )
789
+ self.explicit_content_filter: ExplicitContentFilterLevel = (
790
+ ExplicitContentFilterLevel(data["explicit_content_filter"])
791
+ )
792
+
793
+ self.roles: List[Role] = [Role(r) for r in data.get("roles", [])]
794
+ self.emojis: List[Emoji] = [
795
+ Emoji(e_data, client_instance) for e_data in data.get("emojis", [])
796
+ ]
797
+
798
+ # Assuming GuildFeature can be constructed from string feature names or their values
799
+ self.features: List[GuildFeature] = [
800
+ GuildFeature(f) if not isinstance(f, GuildFeature) else f
801
+ for f in data.get("features", [])
802
+ ]
803
+
804
+ self.mfa_level: MFALevel = MFALevel(data["mfa_level"])
805
+ self.application_id: Optional[str] = data.get("application_id")
806
+ self.system_channel_id: Optional[str] = data.get("system_channel_id")
807
+ self.system_channel_flags: int = data["system_channel_flags"]
808
+ self.rules_channel_id: Optional[str] = data.get("rules_channel_id")
809
+ self.max_members: Optional[int] = data.get("max_members")
810
+ self.vanity_url_code: Optional[str] = data.get("vanity_url_code")
811
+ self.description: Optional[str] = data.get("description")
812
+ self.banner: Optional[str] = data.get("banner")
813
+ self.premium_tier: PremiumTier = PremiumTier(data["premium_tier"])
814
+ self.premium_subscription_count: Optional[int] = data.get(
815
+ "premium_subscription_count"
816
+ )
817
+ self.preferred_locale: str = data.get("preferred_locale", "en-US")
818
+ self.public_updates_channel_id: Optional[str] = data.get(
819
+ "public_updates_channel_id"
820
+ )
821
+ self.max_video_channel_users: Optional[int] = data.get(
822
+ "max_video_channel_users"
823
+ )
824
+ self.approximate_member_count: Optional[int] = data.get(
825
+ "approximate_member_count"
826
+ )
827
+ self.approximate_presence_count: Optional[int] = data.get(
828
+ "approximate_presence_count"
829
+ )
830
+ self.welcome_screen: Optional["WelcomeScreen"] = (
831
+ WelcomeScreen(data["welcome_screen"], client_instance)
832
+ if data.get("welcome_screen")
833
+ else None
834
+ )
835
+ self.nsfw_level: GuildNSFWLevel = GuildNSFWLevel(data["nsfw_level"])
836
+ self.stickers: Optional[List[Sticker]] = (
837
+ [Sticker(s_data, client_instance) for s_data in data.get("stickers", [])]
838
+ if data.get("stickers")
839
+ else None
840
+ )
841
+ self.premium_progress_bar_enabled: bool = data.get(
842
+ "premium_progress_bar_enabled", False
843
+ )
844
+
845
+ # Internal caches, populated by events or specific fetches
846
+ self._channels: Dict[str, "Channel"] = {}
847
+ self._members: Dict[str, Member] = {}
848
+ self._threads: Dict[str, "Thread"] = {}
849
+
850
+ def get_channel(self, channel_id: str) -> Optional["Channel"]:
851
+ return self._channels.get(channel_id)
852
+
853
+ def get_member(self, user_id: str) -> Optional[Member]:
854
+ return self._members.get(user_id)
855
+
856
+ def get_role(self, role_id: str) -> Optional[Role]:
857
+ return next((role for role in self.roles if role.id == role_id), None)
858
+
859
+ def __repr__(self) -> str:
860
+ return f"<Guild id='{self.id}' name='{self.name}'>"
861
+
862
+
863
+ class Channel:
864
+ """Base class for Discord channels."""
865
+
866
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
867
+ self._client: "Client" = client_instance
868
+ self.id: str = data["id"]
869
+ self._type_val: int = int(data["type"]) # Store raw type for enum property
870
+
871
+ self.guild_id: Optional[str] = data.get("guild_id")
872
+ self.name: Optional[str] = data.get("name")
873
+ self.position: Optional[int] = data.get("position")
874
+ self.permission_overwrites: List["PermissionOverwrite"] = [
875
+ PermissionOverwrite(d) for d in data.get("permission_overwrites", [])
876
+ ]
877
+ self.nsfw: Optional[bool] = data.get("nsfw", False)
878
+ self.parent_id: Optional[str] = data.get(
879
+ "parent_id"
880
+ ) # ID of the parent category channel or thread parent
881
+
882
+ @property
883
+ def type(self) -> ChannelType:
884
+ return ChannelType(self._type_val)
885
+
886
+ @property
887
+ def mention(self) -> str:
888
+ return f"<#{self.id}>"
889
+
890
+ async def delete(self, reason: Optional[str] = None):
891
+ await self._client._http.delete_channel(self.id, reason=reason)
892
+
893
+ def __repr__(self) -> str:
894
+ return f"<Channel id='{self.id}' name='{self.name}' type='{self.type.name if hasattr(self.type, 'name') else self._type_val}'>"
895
+
896
+
897
+ class TextChannel(Channel):
898
+ """Represents a guild text channel or announcement channel."""
899
+
900
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
901
+ super().__init__(data, client_instance)
902
+ self.topic: Optional[str] = data.get("topic")
903
+ self.last_message_id: Optional[str] = data.get("last_message_id")
904
+ self.rate_limit_per_user: Optional[int] = data.get("rate_limit_per_user", 0)
905
+ self.default_auto_archive_duration: Optional[int] = data.get(
906
+ "default_auto_archive_duration"
907
+ )
908
+ self.last_pin_timestamp: Optional[str] = data.get("last_pin_timestamp")
909
+
910
+ async def send(
911
+ self,
912
+ content: Optional[str] = None,
913
+ *,
914
+ embed: Optional[Embed] = None,
915
+ embeds: Optional[List[Embed]] = None,
916
+ components: Optional[List["ActionRow"]] = None, # Added components
917
+ ) -> "Message": # Forward reference Message
918
+ if not hasattr(self._client, "send_message"):
919
+ raise NotImplementedError(
920
+ "Client.send_message is required for TextChannel.send"
921
+ )
922
+
923
+ return await self._client.send_message(
924
+ channel_id=self.id,
925
+ content=content,
926
+ embed=embed,
927
+ embeds=embeds,
928
+ components=components,
929
+ )
930
+
931
+ def __repr__(self) -> str:
932
+ return f"<TextChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
933
+
934
+
935
+ class VoiceChannel(Channel):
936
+ """Represents a guild voice channel or stage voice channel."""
937
+
938
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
939
+ super().__init__(data, client_instance)
940
+ self.bitrate: int = data.get("bitrate", 64000)
941
+ self.user_limit: int = data.get("user_limit", 0)
942
+ self.rtc_region: Optional[str] = data.get("rtc_region")
943
+ self.video_quality_mode: Optional[int] = data.get("video_quality_mode")
944
+
945
+ def __repr__(self) -> str:
946
+ return f"<VoiceChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
947
+
948
+
949
+ class CategoryChannel(Channel):
950
+ """Represents a guild category channel."""
951
+
952
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
953
+ super().__init__(data, client_instance)
954
+
955
+ @property
956
+ def channels(self) -> List[Channel]:
957
+ if not self.guild_id or not hasattr(self._client, "get_guild"):
958
+ return []
959
+ guild = self._client.get_guild(self.guild_id)
960
+ if not guild or not hasattr(
961
+ guild, "_channels"
962
+ ): # Ensure guild and _channels exist
963
+ return []
964
+
965
+ categorized_channels = [
966
+ ch
967
+ for ch in guild._channels.values()
968
+ if getattr(ch, "parent_id", None) == self.id
969
+ ]
970
+ return sorted(
971
+ categorized_channels,
972
+ key=lambda c: c.position if c.position is not None else -1,
973
+ )
974
+
975
+ def __repr__(self) -> str:
976
+ return f"<CategoryChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
977
+
978
+
979
+ class ThreadMetadata:
980
+ """Represents the metadata of a thread."""
981
+
982
+ def __init__(self, data: Dict[str, Any]):
983
+ self.archived: bool = data["archived"]
984
+ self.auto_archive_duration: int = data["auto_archive_duration"]
985
+ self.archive_timestamp: str = data["archive_timestamp"]
986
+ self.locked: bool = data["locked"]
987
+ self.invitable: Optional[bool] = data.get("invitable")
988
+ self.create_timestamp: Optional[str] = data.get("create_timestamp")
989
+
990
+
991
+ class Thread(TextChannel): # Threads are a specialized TextChannel
992
+ """Represents a Discord Thread."""
993
+
994
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
995
+ super().__init__(data, client_instance) # Handles common text channel fields
996
+ self.owner_id: Optional[str] = data.get("owner_id")
997
+ # parent_id is already handled by base Channel init if present in data
998
+ self.message_count: Optional[int] = data.get("message_count")
999
+ self.member_count: Optional[int] = data.get("member_count")
1000
+ self.thread_metadata: ThreadMetadata = ThreadMetadata(data["thread_metadata"])
1001
+ self.member: Optional["ThreadMember"] = (
1002
+ ThreadMember(data["member"], client_instance)
1003
+ if data.get("member")
1004
+ else None
1005
+ )
1006
+
1007
+ def __repr__(self) -> str:
1008
+ return (
1009
+ f"<Thread id='{self.id}' name='{self.name}' parent_id='{self.parent_id}'>"
1010
+ )
1011
+
1012
+
1013
+ class DMChannel(Channel):
1014
+ """Represents a Direct Message channel."""
1015
+
1016
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1017
+ super().__init__(data, client_instance)
1018
+ self.last_message_id: Optional[str] = data.get("last_message_id")
1019
+ self.recipients: List[User] = [
1020
+ User(u_data) for u_data in data.get("recipients", [])
1021
+ ]
1022
+
1023
+ @property
1024
+ def recipient(self) -> Optional[User]:
1025
+ return self.recipients[0] if self.recipients else None
1026
+
1027
+ async def send(
1028
+ self,
1029
+ content: Optional[str] = None,
1030
+ *,
1031
+ embed: Optional[Embed] = None,
1032
+ embeds: Optional[List[Embed]] = None,
1033
+ components: Optional[List["ActionRow"]] = None, # Added components
1034
+ ) -> "Message":
1035
+ if not hasattr(self._client, "send_message"):
1036
+ raise NotImplementedError(
1037
+ "Client.send_message is required for DMChannel.send"
1038
+ )
1039
+
1040
+ return await self._client.send_message(
1041
+ channel_id=self.id,
1042
+ content=content,
1043
+ embed=embed,
1044
+ embeds=embeds,
1045
+ components=components,
1046
+ )
1047
+
1048
+ def __repr__(self) -> str:
1049
+ recipient_repr = self.recipient.username if self.recipient else "Unknown"
1050
+ return f"<DMChannel id='{self.id}' recipient='{recipient_repr}'>"
1051
+
1052
+
1053
+ class PartialChannel:
1054
+ """Represents a partial channel object, often from interactions."""
1055
+
1056
+ def __init__(
1057
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1058
+ ):
1059
+ self._client: Optional["Client"] = client_instance
1060
+ self.id: str = data["id"]
1061
+ self.name: Optional[str] = data.get("name")
1062
+ self._type_val: int = int(data["type"])
1063
+ self.permissions: Optional[str] = data.get("permissions")
1064
+
1065
+ @property
1066
+ def type(self) -> ChannelType:
1067
+ return ChannelType(self._type_val)
1068
+
1069
+ @property
1070
+ def mention(self) -> str:
1071
+ return f"<#{self.id}>"
1072
+
1073
+ async def fetch_full_channel(self) -> Optional[Channel]:
1074
+ if not self._client or not hasattr(self._client, "fetch_channel"):
1075
+ # Log or raise if fetching is not possible
1076
+ return None
1077
+ try:
1078
+ # This assumes Client.fetch_channel exists and returns a full Channel object
1079
+ return await self._client.fetch_channel(self.id)
1080
+ except HTTPException as exc:
1081
+ print(f"HTTP error while fetching channel {self.id}: {exc}")
1082
+ except (json.JSONDecodeError, KeyError, ValueError) as exc:
1083
+ print(f"Failed to parse channel {self.id}: {exc}")
1084
+ except DisagreementException as exc:
1085
+ print(f"Error fetching channel {self.id}: {exc}")
1086
+ return None
1087
+
1088
+ def __repr__(self) -> str:
1089
+ type_name = self.type.name if hasattr(self.type, "name") else self._type_val
1090
+ return f"<PartialChannel id='{self.id}' name='{self.name}' type='{type_name}'>"
1091
+
1092
+
1093
+ # --- Message Components ---
1094
+
1095
+
1096
+ class Component:
1097
+ """Base class for message components."""
1098
+
1099
+ def __init__(self, type: ComponentType):
1100
+ self.type: ComponentType = type
1101
+ self.custom_id: Optional[str] = None
1102
+
1103
+ def to_dict(self) -> Dict[str, Any]:
1104
+ payload: Dict[str, Any] = {"type": self.type.value}
1105
+ if self.custom_id:
1106
+ payload["custom_id"] = self.custom_id
1107
+ return payload
1108
+
1109
+
1110
+ class ActionRow(Component):
1111
+ """Represents an Action Row, a container for other components."""
1112
+
1113
+ def __init__(self, components: Optional[List[Component]] = None):
1114
+ super().__init__(ComponentType.ACTION_ROW)
1115
+ self.components: List[Component] = components or []
1116
+
1117
+ def add_component(self, component: Component):
1118
+ if isinstance(component, ActionRow):
1119
+ raise ValueError("Cannot nest ActionRows inside another ActionRow.")
1120
+
1121
+ select_types = {
1122
+ ComponentType.STRING_SELECT,
1123
+ ComponentType.USER_SELECT,
1124
+ ComponentType.ROLE_SELECT,
1125
+ ComponentType.MENTIONABLE_SELECT,
1126
+ ComponentType.CHANNEL_SELECT,
1127
+ }
1128
+
1129
+ if component.type in select_types:
1130
+ if self.components:
1131
+ raise ValueError(
1132
+ "Select menu components must be the only component in an ActionRow."
1133
+ )
1134
+ self.components.append(component)
1135
+ return self
1136
+
1137
+ if any(c.type in select_types for c in self.components):
1138
+ raise ValueError(
1139
+ "Cannot add components to an ActionRow that already contains a select menu."
1140
+ )
1141
+
1142
+ if len(self.components) >= 5:
1143
+ raise ValueError("ActionRow cannot have more than 5 components.")
1144
+
1145
+ self.components.append(component)
1146
+ return self
1147
+
1148
+ def to_dict(self) -> Dict[str, Any]:
1149
+ payload = super().to_dict()
1150
+ payload["components"] = [c.to_dict() for c in self.components]
1151
+ return payload
1152
+
1153
+ @classmethod
1154
+ def from_dict(
1155
+ cls, data: Dict[str, Any], client: Optional["Client"] = None
1156
+ ) -> "ActionRow":
1157
+ """Deserialize an action row payload."""
1158
+ from .components import component_factory
1159
+
1160
+ row = cls()
1161
+ for comp_data in data.get("components", []):
1162
+ try:
1163
+ row.add_component(component_factory(comp_data, client))
1164
+ except Exception:
1165
+ # Skip components that fail to parse for now
1166
+ continue
1167
+ return row
1168
+
1169
+
1170
+ class Button(Component):
1171
+ """Represents a button component."""
1172
+
1173
+ def __init__(
1174
+ self,
1175
+ *, # Make parameters keyword-only for clarity
1176
+ style: ButtonStyle,
1177
+ label: Optional[str] = None,
1178
+ emoji: Optional["PartialEmoji"] = None, # Changed to PartialEmoji type
1179
+ custom_id: Optional[str] = None,
1180
+ url: Optional[str] = None,
1181
+ disabled: bool = False,
1182
+ ):
1183
+ super().__init__(ComponentType.BUTTON)
1184
+
1185
+ if style == ButtonStyle.LINK and url is None:
1186
+ raise ValueError("Link buttons must have a URL.")
1187
+ if style != ButtonStyle.LINK and custom_id is None:
1188
+ raise ValueError("Non-link buttons must have a custom_id.")
1189
+ if label is None and emoji is None:
1190
+ raise ValueError("Button must have a label or an emoji.")
1191
+
1192
+ self.style: ButtonStyle = style
1193
+ self.label: Optional[str] = label
1194
+ self.emoji: Optional[PartialEmoji] = emoji
1195
+ self.custom_id = custom_id
1196
+ self.url: Optional[str] = url
1197
+ self.disabled: bool = disabled
1198
+
1199
+ def to_dict(self) -> Dict[str, Any]:
1200
+ payload = super().to_dict()
1201
+ payload["style"] = self.style.value
1202
+ if self.label:
1203
+ payload["label"] = self.label
1204
+ if self.emoji:
1205
+ payload["emoji"] = self.emoji.to_dict() # Call to_dict()
1206
+ if self.custom_id:
1207
+ payload["custom_id"] = self.custom_id
1208
+ if self.url:
1209
+ payload["url"] = self.url
1210
+ if self.disabled:
1211
+ payload["disabled"] = self.disabled
1212
+ return payload
1213
+
1214
+
1215
+ class SelectOption:
1216
+ """Represents an option in a select menu."""
1217
+
1218
+ def __init__(
1219
+ self,
1220
+ *, # Make parameters keyword-only
1221
+ label: str,
1222
+ value: str,
1223
+ description: Optional[str] = None,
1224
+ emoji: Optional["PartialEmoji"] = None, # Changed to PartialEmoji type
1225
+ default: bool = False,
1226
+ ):
1227
+ self.label: str = label
1228
+ self.value: str = value
1229
+ self.description: Optional[str] = description
1230
+ self.emoji: Optional["PartialEmoji"] = emoji
1231
+ self.default: bool = default
1232
+
1233
+ def to_dict(self) -> Dict[str, Any]:
1234
+ payload: Dict[str, Any] = {
1235
+ "label": self.label,
1236
+ "value": self.value,
1237
+ }
1238
+ if self.description:
1239
+ payload["description"] = self.description
1240
+ if self.emoji:
1241
+ payload["emoji"] = self.emoji.to_dict() # Call to_dict()
1242
+ if self.default:
1243
+ payload["default"] = self.default
1244
+ return payload
1245
+
1246
+
1247
+ class SelectMenu(Component):
1248
+ """Represents a select menu component.
1249
+
1250
+ Currently supports STRING_SELECT (type 3).
1251
+ User (5), Role (6), Mentionable (7), Channel (8) selects are not yet fully modeled.
1252
+ """
1253
+
1254
+ def __init__(
1255
+ self,
1256
+ *, # Make parameters keyword-only
1257
+ custom_id: str,
1258
+ options: List[SelectOption],
1259
+ placeholder: Optional[str] = None,
1260
+ min_values: int = 1,
1261
+ max_values: int = 1,
1262
+ disabled: bool = False,
1263
+ channel_types: Optional[List[ChannelType]] = None,
1264
+ # For other select types, specific fields would be needed.
1265
+ # This constructor primarily targets STRING_SELECT (type 3).
1266
+ type: ComponentType = ComponentType.STRING_SELECT, # Default to string select
1267
+ ):
1268
+ super().__init__(type) # Pass the specific select menu type
1269
+
1270
+ if not (1 <= len(options) <= 25):
1271
+ raise ValueError("Select menu must have between 1 and 25 options.")
1272
+ if not (
1273
+ 0 <= min_values <= 25
1274
+ ): # Discord docs say min_values can be 0 for some types
1275
+ raise ValueError("min_values must be between 0 and 25.")
1276
+ if not (1 <= max_values <= 25):
1277
+ raise ValueError("max_values must be between 1 and 25.")
1278
+ if min_values > max_values:
1279
+ raise ValueError("min_values cannot be greater than max_values.")
1280
+
1281
+ self.custom_id = custom_id
1282
+ self.options: List[SelectOption] = options
1283
+ self.placeholder: Optional[str] = placeholder
1284
+ self.min_values: int = min_values
1285
+ self.max_values: int = max_values
1286
+ self.disabled: bool = disabled
1287
+ self.channel_types: Optional[List[ChannelType]] = channel_types
1288
+
1289
+ def to_dict(self) -> Dict[str, Any]:
1290
+ payload = super().to_dict() # Gets {"type": self.type.value}
1291
+ payload["custom_id"] = self.custom_id
1292
+ payload["options"] = [opt.to_dict() for opt in self.options]
1293
+ if self.placeholder:
1294
+ payload["placeholder"] = self.placeholder
1295
+ payload["min_values"] = self.min_values
1296
+ payload["max_values"] = self.max_values
1297
+ if self.disabled:
1298
+ payload["disabled"] = self.disabled
1299
+ if self.type == ComponentType.CHANNEL_SELECT and self.channel_types:
1300
+ payload["channel_types"] = [ct.value for ct in self.channel_types]
1301
+ return payload
1302
+
1303
+
1304
+ class UnfurledMediaItem:
1305
+ """Represents an unfurled media item."""
1306
+
1307
+ def __init__(
1308
+ self,
1309
+ url: str,
1310
+ proxy_url: Optional[str] = None,
1311
+ height: Optional[int] = None,
1312
+ width: Optional[int] = None,
1313
+ content_type: Optional[str] = None,
1314
+ ):
1315
+ self.url = url
1316
+ self.proxy_url = proxy_url
1317
+ self.height = height
1318
+ self.width = width
1319
+ self.content_type = content_type
1320
+
1321
+ def to_dict(self) -> Dict[str, Any]:
1322
+ return {
1323
+ "url": self.url,
1324
+ "proxy_url": self.proxy_url,
1325
+ "height": self.height,
1326
+ "width": self.width,
1327
+ "content_type": self.content_type,
1328
+ }
1329
+
1330
+
1331
+ class MediaGalleryItem:
1332
+ """Represents an item in a media gallery."""
1333
+
1334
+ def __init__(
1335
+ self,
1336
+ media: UnfurledMediaItem,
1337
+ description: Optional[str] = None,
1338
+ spoiler: bool = False,
1339
+ ):
1340
+ self.media = media
1341
+ self.description = description
1342
+ self.spoiler = spoiler
1343
+
1344
+ def to_dict(self) -> Dict[str, Any]:
1345
+ return {
1346
+ "media": self.media.to_dict(),
1347
+ "description": self.description,
1348
+ "spoiler": self.spoiler,
1349
+ }
1350
+
1351
+
1352
+ class TextDisplay(Component):
1353
+ """Represents a text display component."""
1354
+
1355
+ def __init__(self, content: str, id: Optional[int] = None):
1356
+ super().__init__(ComponentType.TEXT_DISPLAY)
1357
+ self.content = content
1358
+ self.id = id
1359
+
1360
+ def to_dict(self) -> Dict[str, Any]:
1361
+ payload = super().to_dict()
1362
+ payload["content"] = self.content
1363
+ if self.id is not None:
1364
+ payload["id"] = self.id
1365
+ return payload
1366
+
1367
+
1368
+ class Thumbnail(Component):
1369
+ """Represents a thumbnail component."""
1370
+
1371
+ def __init__(
1372
+ self,
1373
+ media: UnfurledMediaItem,
1374
+ description: Optional[str] = None,
1375
+ spoiler: bool = False,
1376
+ id: Optional[int] = None,
1377
+ ):
1378
+ super().__init__(ComponentType.THUMBNAIL)
1379
+ self.media = media
1380
+ self.description = description
1381
+ self.spoiler = spoiler
1382
+ self.id = id
1383
+
1384
+ def to_dict(self) -> Dict[str, Any]:
1385
+ payload = super().to_dict()
1386
+ payload["media"] = self.media.to_dict()
1387
+ if self.description:
1388
+ payload["description"] = self.description
1389
+ if self.spoiler:
1390
+ payload["spoiler"] = self.spoiler
1391
+ if self.id is not None:
1392
+ payload["id"] = self.id
1393
+ return payload
1394
+
1395
+
1396
+ class Section(Component):
1397
+ """Represents a section component."""
1398
+
1399
+ def __init__(
1400
+ self,
1401
+ components: List[TextDisplay],
1402
+ accessory: Optional[Union[Thumbnail, Button]] = None,
1403
+ id: Optional[int] = None,
1404
+ ):
1405
+ super().__init__(ComponentType.SECTION)
1406
+ self.components = components
1407
+ self.accessory = accessory
1408
+ self.id = id
1409
+
1410
+ def to_dict(self) -> Dict[str, Any]:
1411
+ payload = super().to_dict()
1412
+ payload["components"] = [c.to_dict() for c in self.components]
1413
+ if self.accessory:
1414
+ payload["accessory"] = self.accessory.to_dict()
1415
+ if self.id is not None:
1416
+ payload["id"] = self.id
1417
+ return payload
1418
+
1419
+
1420
+ class MediaGallery(Component):
1421
+ """Represents a media gallery component."""
1422
+
1423
+ def __init__(self, items: List[MediaGalleryItem], id: Optional[int] = None):
1424
+ super().__init__(ComponentType.MEDIA_GALLERY)
1425
+ self.items = items
1426
+ self.id = id
1427
+
1428
+ def to_dict(self) -> Dict[str, Any]:
1429
+ payload = super().to_dict()
1430
+ payload["items"] = [i.to_dict() for i in self.items]
1431
+ if self.id is not None:
1432
+ payload["id"] = self.id
1433
+ return payload
1434
+
1435
+
1436
+ class File(Component):
1437
+ """Represents a file component."""
1438
+
1439
+ def __init__(
1440
+ self, file: UnfurledMediaItem, spoiler: bool = False, id: Optional[int] = None
1441
+ ):
1442
+ super().__init__(ComponentType.FILE)
1443
+ self.file = file
1444
+ self.spoiler = spoiler
1445
+ self.id = id
1446
+
1447
+ def to_dict(self) -> Dict[str, Any]:
1448
+ payload = super().to_dict()
1449
+ payload["file"] = self.file.to_dict()
1450
+ if self.spoiler:
1451
+ payload["spoiler"] = self.spoiler
1452
+ if self.id is not None:
1453
+ payload["id"] = self.id
1454
+ return payload
1455
+
1456
+
1457
+ class Separator(Component):
1458
+ """Represents a separator component."""
1459
+
1460
+ def __init__(
1461
+ self, divider: bool = True, spacing: int = 1, id: Optional[int] = None
1462
+ ):
1463
+ super().__init__(ComponentType.SEPARATOR)
1464
+ self.divider = divider
1465
+ self.spacing = spacing
1466
+ self.id = id
1467
+
1468
+ def to_dict(self) -> Dict[str, Any]:
1469
+ payload = super().to_dict()
1470
+ payload["divider"] = self.divider
1471
+ payload["spacing"] = self.spacing
1472
+ if self.id is not None:
1473
+ payload["id"] = self.id
1474
+ return payload
1475
+
1476
+
1477
+ class Container(Component):
1478
+ """Represents a container component."""
1479
+
1480
+ def __init__(
1481
+ self,
1482
+ components: List[Component],
1483
+ accent_color: Optional[int] = None,
1484
+ spoiler: bool = False,
1485
+ id: Optional[int] = None,
1486
+ ):
1487
+ super().__init__(ComponentType.CONTAINER)
1488
+ self.components = components
1489
+ self.accent_color = accent_color
1490
+ self.spoiler = spoiler
1491
+ self.id = id
1492
+
1493
+ def to_dict(self) -> Dict[str, Any]:
1494
+ payload = super().to_dict()
1495
+ payload["components"] = [c.to_dict() for c in self.components]
1496
+ if self.accent_color:
1497
+ payload["accent_color"] = self.accent_color
1498
+ if self.spoiler:
1499
+ payload["spoiler"] = self.spoiler
1500
+ if self.id is not None:
1501
+ payload["id"] = self.id
1502
+ return payload
1503
+
1504
+
1505
+ class WelcomeChannel:
1506
+ """Represents a channel shown in the server's welcome screen.
1507
+
1508
+ Attributes:
1509
+ channel_id (str): The ID of the channel.
1510
+ description (str): The description shown for the channel.
1511
+ emoji_id (Optional[str]): The ID of the emoji, if custom.
1512
+ emoji_name (Optional[str]): The name of the emoji if custom, or the unicode character if standard.
1513
+ """
1514
+
1515
+ def __init__(self, data: Dict[str, Any]):
1516
+ self.channel_id: str = data["channel_id"]
1517
+ self.description: str = data["description"]
1518
+ self.emoji_id: Optional[str] = data.get("emoji_id")
1519
+ self.emoji_name: Optional[str] = data.get("emoji_name")
1520
+
1521
+ def __repr__(self) -> str:
1522
+ return (
1523
+ f"<WelcomeChannel id='{self.channel_id}' description='{self.description}'>"
1524
+ )
1525
+
1526
+
1527
+ class WelcomeScreen:
1528
+ """Represents the welcome screen of a Community guild.
1529
+
1530
+ Attributes:
1531
+ description (Optional[str]): The server description shown in the welcome screen.
1532
+ welcome_channels (List[WelcomeChannel]): The channels shown in the welcome screen.
1533
+ """
1534
+
1535
+ def __init__(self, data: Dict[str, Any], client_instance: "Client"):
1536
+ self._client: "Client" = (
1537
+ client_instance # May be useful for fetching channel objects
1538
+ )
1539
+ self.description: Optional[str] = data.get("description")
1540
+ self.welcome_channels: List[WelcomeChannel] = [
1541
+ WelcomeChannel(wc_data) for wc_data in data.get("welcome_channels", [])
1542
+ ]
1543
+
1544
+ def __repr__(self) -> str:
1545
+ return f"<WelcomeScreen description='{self.description}' channels={len(self.welcome_channels)}>"
1546
+
1547
+
1548
+ class ThreadMember:
1549
+ """Represents a member of a thread.
1550
+
1551
+ Attributes:
1552
+ id (Optional[str]): The ID of the thread. Not always present.
1553
+ user_id (Optional[str]): The ID of the user. Not always present.
1554
+ join_timestamp (str): When the user joined the thread (ISO8601 timestamp).
1555
+ flags (int): User-specific flags for thread settings.
1556
+ member (Optional[Member]): The guild member object for this user, if resolved.
1557
+ Only available from GUILD_MEMBERS intent and if fetched.
1558
+ """
1559
+
1560
+ def __init__(
1561
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1562
+ ): # client_instance for member resolution
1563
+ self._client: Optional["Client"] = client_instance
1564
+ self.id: Optional[str] = data.get("id") # Thread ID
1565
+ self.user_id: Optional[str] = data.get("user_id")
1566
+ self.join_timestamp: str = data["join_timestamp"]
1567
+ self.flags: int = data["flags"]
1568
+
1569
+ # The 'member' field in ThreadMember payload is a full guild member object.
1570
+ # This is present in some contexts like when listing thread members.
1571
+ self.member: Optional[Member] = (
1572
+ Member(data["member"], client_instance) if data.get("member") else None
1573
+ )
1574
+
1575
+ # Note: The 'presence' field is not included as it's often unavailable or too dynamic for a simple model.
1576
+
1577
+ def __repr__(self) -> str:
1578
+ return f"<ThreadMember user_id='{self.user_id}' thread_id='{self.id}'>"
1579
+
1580
+
1581
+ class PresenceUpdate:
1582
+ """Represents a PRESENCE_UPDATE event."""
1583
+
1584
+ def __init__(
1585
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1586
+ ):
1587
+ self._client = client_instance
1588
+ self.user = User(data["user"])
1589
+ self.guild_id: Optional[str] = data.get("guild_id")
1590
+ self.status: Optional[str] = data.get("status")
1591
+ self.activities: List[Dict[str, Any]] = data.get("activities", [])
1592
+ self.client_status: Dict[str, Any] = data.get("client_status", {})
1593
+
1594
+ def __repr__(self) -> str:
1595
+ return f"<PresenceUpdate user_id='{self.user.id}' guild_id='{self.guild_id}' status='{self.status}'>"
1596
+
1597
+
1598
+ class TypingStart:
1599
+ """Represents a TYPING_START event."""
1600
+
1601
+ def __init__(
1602
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1603
+ ):
1604
+ self._client = client_instance
1605
+ self.channel_id: str = data["channel_id"]
1606
+ self.guild_id: Optional[str] = data.get("guild_id")
1607
+ self.user_id: str = data["user_id"]
1608
+ self.timestamp: int = data["timestamp"]
1609
+ self.member: Optional[Member] = (
1610
+ Member(data["member"], client_instance) if data.get("member") else None
1611
+ )
1612
+
1613
+ def __repr__(self) -> str:
1614
+ return f"<TypingStart channel_id='{self.channel_id}' user_id='{self.user_id}'>"
1615
+
1616
+
1617
+ def channel_factory(data: Dict[str, Any], client: "Client") -> Channel:
1618
+ """Create a channel object from raw API data."""
1619
+ channel_type = data.get("type")
1620
+
1621
+ if channel_type in (
1622
+ ChannelType.GUILD_TEXT.value,
1623
+ ChannelType.GUILD_ANNOUNCEMENT.value,
1624
+ ):
1625
+ return TextChannel(data, client)
1626
+ if channel_type in (
1627
+ ChannelType.GUILD_VOICE.value,
1628
+ ChannelType.GUILD_STAGE_VOICE.value,
1629
+ ):
1630
+ return VoiceChannel(data, client)
1631
+ if channel_type == ChannelType.GUILD_CATEGORY.value:
1632
+ return CategoryChannel(data, client)
1633
+ if channel_type in (
1634
+ ChannelType.ANNOUNCEMENT_THREAD.value,
1635
+ ChannelType.PUBLIC_THREAD.value,
1636
+ ChannelType.PRIVATE_THREAD.value,
1637
+ ):
1638
+ return Thread(data, client)
1639
+ if channel_type in (ChannelType.DM.value, ChannelType.GROUP_DM.value):
1640
+ return DMChannel(data, client)
1641
+
1642
+ return Channel(data, client)