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