disagreement 0.2.0rc1__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/client.py CHANGED
@@ -1,1545 +1,1658 @@
1
- # disagreement/client.py
2
-
3
- """
4
- The main Client class for interacting with the Discord API.
5
- """
6
-
7
- import asyncio
8
- import signal
9
- from typing import (
10
- Optional,
11
- Callable,
12
- Any,
13
- TYPE_CHECKING,
14
- Awaitable,
15
- AsyncIterator,
16
- Union,
17
- List,
18
- Dict,
19
- )
20
- from types import ModuleType
21
-
22
- from .http import HTTPClient
23
- from .gateway import GatewayClient
24
- from .shard_manager import ShardManager
25
- from .event_dispatcher import EventDispatcher
26
- from .enums import GatewayIntent, InteractionType, GatewayOpcode, VoiceRegion
27
- from .errors import DisagreementException, AuthenticationError
28
- from .typing import Typing
29
- from .ext.commands.core import CommandHandler
30
- from .ext.commands.cog import Cog
31
- from .ext.app_commands.handler import AppCommandHandler
32
- from .ext.app_commands.context import AppCommandContext
33
- from .ext import loader as ext_loader
34
- from .interactions import Interaction, Snowflake
35
- from .error_handler import setup_global_error_handler
36
- from .voice_client import VoiceClient
37
-
38
- if TYPE_CHECKING:
39
- from .models import (
40
- Message,
41
- Embed,
42
- ActionRow,
43
- Guild,
44
- Channel,
45
- User,
46
- Member,
47
- Role,
48
- TextChannel,
49
- VoiceChannel,
50
- CategoryChannel,
51
- Thread,
52
- DMChannel,
53
- Webhook,
54
- GuildTemplate,
55
- ScheduledEvent,
56
- AuditLogEntry,
57
- Invite,
58
- )
59
- from .ui.view import View
60
- from .enums import ChannelType as EnumChannelType
61
- from .ext.commands.core import CommandContext
62
- from .ext.commands.errors import CommandError, CommandInvokeError
63
- from .ext.app_commands.commands import AppCommand, AppCommandGroup
64
-
65
-
66
- class Client:
67
- """
68
- Represents a client connection that connects to Discord.
69
- This class is used to interact with the Discord WebSocket and API.
70
-
71
- Args:
72
- token (str): The bot token for authentication.
73
- intents (Optional[int]): The Gateway Intents to use. Defaults to `GatewayIntent.default()`.
74
- You might need to enable privileged intents in your bot's application page.
75
- loop (Optional[asyncio.AbstractEventLoop]): The event loop to use for asynchronous operations.
76
- Defaults to `asyncio.get_event_loop()`.
77
- command_prefix (Union[str, List[str], Callable[['Client', Message], Union[str, List[str]]]]):
78
- The prefix(es) for commands. Defaults to '!'.
79
- verbose (bool): If True, print raw HTTP and Gateway traffic for debugging.
80
- http_options (Optional[Dict[str, Any]]): Extra options passed to
81
- :class:`HTTPClient` for creating the internal
82
- :class:`aiohttp.ClientSession`.
83
- """
84
-
85
- def __init__(
86
- self,
87
- token: str,
88
- intents: Optional[int] = None,
89
- loop: Optional[asyncio.AbstractEventLoop] = None,
90
- command_prefix: Union[
91
- str, List[str], Callable[["Client", "Message"], Union[str, List[str]]]
92
- ] = "!",
93
- application_id: Optional[Union[str, int]] = None,
94
- verbose: bool = False,
95
- mention_replies: bool = False,
96
- shard_count: Optional[int] = None,
97
- gateway_max_retries: int = 5,
98
- gateway_max_backoff: float = 60.0,
99
- http_options: Optional[Dict[str, Any]] = None,
100
- ):
101
- if not token:
102
- raise ValueError("A bot token must be provided.")
103
-
104
- self.token: str = token
105
- self.intents: int = intents if intents is not None else GatewayIntent.default()
106
- self.loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop()
107
- self.application_id: Optional[Snowflake] = (
108
- str(application_id) if application_id else None
109
- )
110
- setup_global_error_handler(self.loop)
111
-
112
- self.verbose: bool = verbose
113
- self._http: HTTPClient = HTTPClient(
114
- token=self.token,
115
- verbose=verbose,
116
- **(http_options or {}),
117
- )
118
- self._event_dispatcher: EventDispatcher = EventDispatcher(client_instance=self)
119
- self._gateway: Optional[GatewayClient] = (
120
- None # Initialized in run() or connect()
121
- )
122
- self.shard_count: Optional[int] = shard_count
123
- self.gateway_max_retries: int = gateway_max_retries
124
- self.gateway_max_backoff: float = gateway_max_backoff
125
- self._shard_manager: Optional[ShardManager] = None
126
-
127
- # Initialize CommandHandler
128
- self.command_handler: CommandHandler = CommandHandler(
129
- client=self, prefix=command_prefix
130
- )
131
- self.app_command_handler: AppCommandHandler = AppCommandHandler(client=self)
132
- # Register internal listener for processing commands from messages
133
- self._event_dispatcher.register(
134
- "MESSAGE_CREATE", self._process_message_for_commands
135
- )
136
-
137
- self._closed: bool = False
138
- self._ready_event: asyncio.Event = asyncio.Event()
139
- self.user: Optional["User"] = (
140
- None # The bot's own user object, populated on READY
141
- )
142
-
143
- # Internal Caches
144
- self._guilds: Dict[Snowflake, "Guild"] = {}
145
- self._channels: Dict[Snowflake, "Channel"] = (
146
- {}
147
- ) # Stores all channel types by ID
148
- self._users: Dict[Snowflake, Any] = (
149
- {}
150
- ) # Placeholder for User model cache if needed
151
- self._messages: Dict[Snowflake, "Message"] = {}
152
- self._views: Dict[Snowflake, "View"] = {}
153
- self._voice_clients: Dict[Snowflake, VoiceClient] = {}
154
- self._webhooks: Dict[Snowflake, "Webhook"] = {}
155
-
156
- # Default whether replies mention the user
157
- self.mention_replies: bool = mention_replies
158
-
159
- # Basic signal handling for graceful shutdown
160
- # This might be better handled by the user's application code, but can be a nice default.
161
- # For more robust handling, consider libraries or more advanced patterns.
162
- try:
163
- self.loop.add_signal_handler(
164
- signal.SIGINT, lambda: self.loop.create_task(self.close())
165
- )
166
- self.loop.add_signal_handler(
167
- signal.SIGTERM, lambda: self.loop.create_task(self.close())
168
- )
169
- except NotImplementedError:
170
- # add_signal_handler is not available on all platforms (e.g., Windows default event loop policy)
171
- # Users on these platforms would need to handle shutdown differently.
172
- print(
173
- "Warning: Signal handlers for SIGINT/SIGTERM could not be added. "
174
- "Graceful shutdown via signals might not work as expected on this platform."
175
- )
176
-
177
- async def _initialize_gateway(self):
178
- """Initializes the GatewayClient if it doesn't exist."""
179
- if self._gateway is None:
180
- self._gateway = GatewayClient(
181
- http_client=self._http,
182
- event_dispatcher=self._event_dispatcher,
183
- token=self.token,
184
- intents=self.intents,
185
- client_instance=self,
186
- verbose=self.verbose,
187
- max_retries=self.gateway_max_retries,
188
- max_backoff=self.gateway_max_backoff,
189
- )
190
-
191
- async def _initialize_shard_manager(self) -> None:
192
- """Initializes the :class:`ShardManager` if not already created."""
193
- if self._shard_manager is None:
194
- count = self.shard_count or 1
195
- self._shard_manager = ShardManager(self, count)
196
-
197
- async def connect(self, reconnect: bool = True) -> None:
198
- """
199
- Establishes a connection to Discord. This includes logging in and connecting to the Gateway.
200
- This method is a coroutine.
201
-
202
- Args:
203
- reconnect (bool): Whether to automatically attempt to reconnect on disconnect.
204
- (Note: Basic reconnect logic is within GatewayClient for now)
205
-
206
- Raises:
207
- GatewayException: If the connection to the gateway fails.
208
- AuthenticationError: If the token is invalid.
209
- """
210
- if self._closed:
211
- raise DisagreementException("Client is closed and cannot connect.")
212
- if self.shard_count and self.shard_count > 1:
213
- await self._initialize_shard_manager()
214
- assert self._shard_manager is not None
215
- await self._shard_manager.start()
216
- print(
217
- f"Client connected using {self.shard_count} shards, waiting for READY signal..."
218
- )
219
- await self.wait_until_ready()
220
- print("Client is READY!")
221
- return
222
-
223
- await self._initialize_gateway()
224
- assert self._gateway is not None # Should be initialized by now
225
-
226
- retry_delay = 5 # seconds
227
- max_retries = 5 # For initial connection attempts by Client.run, Gateway has its own internal retries for some cases.
228
-
229
- for attempt in range(max_retries):
230
- try:
231
- await self._gateway.connect()
232
- # After successful connection, GatewayClient's HELLO handler will trigger IDENTIFY/RESUME
233
- # and its READY handler will set self._ready_event via dispatcher.
234
- print("Client connected to Gateway, waiting for READY signal...")
235
- await self.wait_until_ready() # Wait for the READY event from Gateway
236
- print("Client is READY!")
237
- return # Successfully connected and ready
238
- except AuthenticationError: # Non-recoverable by retry here
239
- print("Authentication failed. Please check your bot token.")
240
- await self.close() # Ensure cleanup
241
- raise
242
- except DisagreementException as e: # Includes GatewayException
243
- print(f"Failed to connect (Attempt {attempt + 1}/{max_retries}): {e}")
244
- if attempt < max_retries - 1:
245
- print(f"Retrying in {retry_delay} seconds...")
246
- await asyncio.sleep(retry_delay)
247
- retry_delay = min(
248
- retry_delay * 2, 60
249
- ) # Exponential backoff up to 60s
250
- else:
251
- print("Max connection retries reached. Giving up.")
252
- await self.close() # Ensure cleanup
253
- raise
254
- # Should not be reached if max_retries is > 0
255
- if max_retries == 0: # If max_retries was 0, means no retries attempted
256
- raise DisagreementException("Connection failed with 0 retries allowed.")
257
-
258
- async def run(self) -> None:
259
- """
260
- A blocking call that connects the client to Discord and runs until the client is closed.
261
- This method is a coroutine.
262
- It handles login, Gateway connection, and keeping the connection alive.
263
- """
264
- if self._closed:
265
- raise DisagreementException("Client is already closed.")
266
-
267
- try:
268
- await self.connect()
269
- # The GatewayClient's _receive_loop will keep running.
270
- # This run method effectively waits until the client is closed or an unhandled error occurs.
271
- # A more robust implementation might have a main loop here that monitors gateway health.
272
- # For now, we rely on the gateway's tasks.
273
- while not self._closed:
274
- if (
275
- self._gateway
276
- and self._gateway._receive_task
277
- and self._gateway._receive_task.done()
278
- ):
279
- # If receive task ended unexpectedly, try to handle it or re-raise
280
- try:
281
- exc = self._gateway._receive_task.exception()
282
- if exc:
283
- print(
284
- f"Gateway receive task ended with exception: {exc}. Attempting to reconnect..."
285
- )
286
- # This is a basic reconnect strategy from the client side.
287
- # GatewayClient itself might handle some reconnects.
288
- await self.close_gateway(
289
- code=1000
290
- ) # Close current gateway state
291
- await asyncio.sleep(5) # Wait before reconnecting
292
- if (
293
- not self._closed
294
- ): # If client wasn't closed by the exception handler
295
- await self.connect()
296
- else:
297
- break # Client was closed, exit run loop
298
- else:
299
- print(
300
- "Gateway receive task ended without exception. Assuming clean shutdown or reconnect handled internally."
301
- )
302
- if (
303
- not self._closed
304
- ): # If not explicitly closed, might be an issue
305
- print(
306
- "Warning: Gateway receive task ended but client not closed. This might indicate an issue."
307
- )
308
- # Consider a more robust health check or reconnect strategy here.
309
- await asyncio.sleep(
310
- 1
311
- ) # Prevent tight loop if something is wrong
312
- else:
313
- break # Client was closed
314
- except asyncio.CancelledError:
315
- print("Gateway receive task was cancelled.")
316
- break # Exit if cancelled
317
- except Exception as e:
318
- print(f"Error checking gateway receive task: {e}")
319
- break # Exit on other errors
320
- await asyncio.sleep(1) # Main loop check interval
321
- except DisagreementException as e:
322
- print(f"Client run loop encountered an error: {e}")
323
- # Error already logged by connect or other methods
324
- except asyncio.CancelledError:
325
- print("Client run loop was cancelled.")
326
- finally:
327
- if not self._closed:
328
- await self.close()
329
-
330
- async def close(self) -> None:
331
- """
332
- Closes the connection to Discord. This method is a coroutine.
333
- """
334
- if self._closed:
335
- return
336
-
337
- self._closed = True
338
- print("Closing client...")
339
-
340
- if self._shard_manager:
341
- await self._shard_manager.close()
342
- self._shard_manager = None
343
- if self._gateway:
344
- await self._gateway.close()
345
-
346
- if self._http: # HTTPClient has its own session to close
347
- await self._http.close()
348
-
349
- self._ready_event.set() # Ensure any waiters for ready are unblocked
350
- print("Client closed.")
351
-
352
- async def __aenter__(self) -> "Client":
353
- """Enter the context manager by connecting to Discord."""
354
- await self.connect()
355
- return self
356
-
357
- async def __aexit__(
358
- self,
359
- exc_type: Optional[type],
360
- exc: Optional[BaseException],
361
- tb: Optional[BaseException],
362
- ) -> bool:
363
- """Exit the context manager and close the client."""
364
- await self.close()
365
- return False
366
-
367
- async def close_gateway(self, code: int = 1000) -> None:
368
- """Closes only the gateway connection, allowing for potential reconnect."""
369
- if self._shard_manager:
370
- await self._shard_manager.close()
371
- self._shard_manager = None
372
- if self._gateway:
373
- await self._gateway.close(code=code)
374
- self._gateway = None
375
- self._ready_event.clear() # No longer ready if gateway is closed
376
-
377
- def is_closed(self) -> bool:
378
- """Indicates if the client has been closed."""
379
- return self._closed
380
-
381
- def is_ready(self) -> bool:
382
- """Indicates if the client has successfully connected to the Gateway and is ready."""
383
- return self._ready_event.is_set()
384
-
385
- @property
386
- def latency(self) -> Optional[float]:
387
- """Returns the gateway latency in seconds, or ``None`` if unavailable."""
388
- if self._gateway:
389
- return self._gateway.latency
390
- return None
391
-
392
- async def wait_until_ready(self) -> None:
393
- """|coro|
394
- Waits until the client is fully connected to Discord and the initial state is processed.
395
- This is mainly useful for waiting for the READY event from the Gateway.
396
- """
397
- await self._ready_event.wait()
398
-
399
- async def wait_for(
400
- self,
401
- event_name: str,
402
- check: Optional[Callable[[Any], bool]] = None,
403
- timeout: Optional[float] = None,
404
- ) -> Any:
405
- """|coro|
406
- Waits for a specific event to occur that satisfies the ``check``.
407
-
408
- Parameters
409
- ----------
410
- event_name: str
411
- The name of the event to wait for.
412
- check: Optional[Callable[[Any], bool]]
413
- A function that determines whether the received event should resolve the wait.
414
- timeout: Optional[float]
415
- How long to wait for the event before raising :class:`asyncio.TimeoutError`.
416
- """
417
-
418
- future: asyncio.Future = self.loop.create_future()
419
- self._event_dispatcher.add_waiter(event_name, future, check)
420
- try:
421
- return await asyncio.wait_for(future, timeout=timeout)
422
- finally:
423
- self._event_dispatcher.remove_waiter(event_name, future)
424
-
425
- async def change_presence(
426
- self,
427
- status: str,
428
- activity_name: Optional[str] = None,
429
- activity_type: int = 0,
430
- since: int = 0,
431
- afk: bool = False,
432
- ):
433
- """
434
- Changes the client's presence on Discord.
435
-
436
- Args:
437
- status (str): The new status for the client (e.g., "online", "idle", "dnd", "invisible").
438
- activity_name (Optional[str]): The name of the activity.
439
- activity_type (int): The type of the activity.
440
- since (int): The timestamp (in milliseconds) of when the client went idle.
441
- afk (bool): Whether the client is AFK.
442
- """
443
- if self._closed:
444
- raise DisagreementException("Client is closed.")
445
-
446
- if self._gateway:
447
- await self._gateway.update_presence(
448
- status=status,
449
- activity_name=activity_name,
450
- activity_type=activity_type,
451
- since=since,
452
- afk=afk,
453
- )
454
-
455
- # --- Event Handling ---
456
-
457
- def event(
458
- self, coro: Callable[..., Awaitable[None]]
459
- ) -> Callable[..., Awaitable[None]]:
460
- """
461
- A decorator that registers an event to listen to.
462
- The name of the coroutine is used as the event name.
463
- Example:
464
- @client.event
465
- async def on_ready(): # Will listen for the 'READY' event
466
- print("Bot is ready!")
467
-
468
- @client.event
469
- async def on_message(message: disagreement.Message): # Will listen for 'MESSAGE_CREATE'
470
- print(f"Message from {message.author}: {message.content}")
471
- """
472
- if not asyncio.iscoroutinefunction(coro):
473
- raise TypeError("Event registered must be a coroutine function.")
474
-
475
- event_name = coro.__name__
476
- # Map common function names to Discord event types
477
- # e.g., on_ready -> READY, on_message -> MESSAGE_CREATE
478
- if event_name.startswith("on_"):
479
- discord_event_name = event_name[3:].upper()
480
- mapping = {
481
- "MESSAGE": "MESSAGE_CREATE",
482
- "MESSAGE_EDIT": "MESSAGE_UPDATE",
483
- "MESSAGE_UPDATE": "MESSAGE_UPDATE",
484
- "MESSAGE_DELETE": "MESSAGE_DELETE",
485
- "REACTION_ADD": "MESSAGE_REACTION_ADD",
486
- "REACTION_REMOVE": "MESSAGE_REACTION_REMOVE",
487
- }
488
- discord_event_name = mapping.get(discord_event_name, discord_event_name)
489
- self._event_dispatcher.register(discord_event_name, coro)
490
- else:
491
- # If not starting with "on_", assume it's the direct Discord event name (e.g. "TYPING_START")
492
- # Or raise an error if a specific format is required.
493
- # For now, let's assume direct mapping if no "on_" prefix.
494
- self._event_dispatcher.register(event_name.upper(), coro)
495
-
496
- return coro # Return the original coroutine
497
-
498
- def on_event(
499
- self, event_name: str
500
- ) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
501
- """
502
- A decorator that registers an event to listen to with a specific event name.
503
- Example:
504
- @client.on_event('MESSAGE_CREATE')
505
- async def my_message_handler(message: disagreement.Message):
506
- print(f"Message: {message.content}")
507
- """
508
-
509
- def decorator(
510
- coro: Callable[..., Awaitable[None]],
511
- ) -> Callable[..., Awaitable[None]]:
512
- if not asyncio.iscoroutinefunction(coro):
513
- raise TypeError("Event registered must be a coroutine function.")
514
- self._event_dispatcher.register(event_name.upper(), coro)
515
- return coro
516
-
517
- return decorator
518
-
519
- async def _process_message_for_commands(self, message: "Message") -> None:
520
- """Internal listener to process messages for commands."""
521
- # Make sure message object is valid and not from a bot (optional, common check)
522
- if (
523
- not message or not message.author or message.author.bot
524
- ): # Add .bot check to User model
525
- return
526
- await self.command_handler.process_commands(message)
527
-
528
- # --- Command Framework Methods ---
529
-
530
- def add_cog(self, cog: Cog) -> None:
531
- """
532
- Adds a Cog to the bot.
533
- Cogs are classes that group commands, listeners, and state.
534
- This will also discover and register any application commands defined in the cog.
535
-
536
- Args:
537
- cog (Cog): An instance of a class derived from `disagreement.ext.commands.Cog`.
538
- """
539
- # Add to prefix command handler
540
- self.command_handler.add_cog(
541
- cog
542
- ) # This should call cog._inject() internally or cog._inject() is called on Cog init
543
-
544
- # Discover and add application commands from the cog
545
- # AppCommand and AppCommandGroup are already imported in TYPE_CHECKING block
546
- for app_cmd_obj in cog.get_app_commands_and_groups(): # Uses the new method
547
- # The cog attribute should have been set within Cog._inject() for AppCommands
548
- self.app_command_handler.add_command(app_cmd_obj)
549
- print(
550
- f"Registered app command/group '{app_cmd_obj.name}' from cog '{cog.cog_name}'."
551
- )
552
-
553
- def remove_cog(self, cog_name: str) -> Optional[Cog]:
554
- """
555
- Removes a Cog from the bot.
556
-
557
- Args:
558
- cog_name (str): The name of the Cog to remove.
559
-
560
- Returns:
561
- Optional[Cog]: The Cog that was removed, or None if not found.
562
- """
563
- removed_cog = self.command_handler.remove_cog(cog_name)
564
- if removed_cog:
565
- # Also remove associated application commands
566
- # This requires AppCommand to store a reference to its cog, or iterate all app_commands.
567
- # Assuming AppCommand has a .cog attribute, which is set in Cog._inject()
568
- # And AppCommandGroup might store commands that have .cog attribute
569
- for app_cmd_or_group in removed_cog.get_app_commands_and_groups():
570
- # The AppCommandHandler.remove_command needs to handle both AppCommand and AppCommandGroup
571
- self.app_command_handler.remove_command(
572
- app_cmd_or_group.name
573
- ) # Assuming name is unique enough for removal here
574
- print(
575
- f"Removed app command/group '{app_cmd_or_group.name}' from cog '{cog_name}'."
576
- )
577
- # Note: AppCommandHandler.remove_command might need to be more specific if names aren't globally unique
578
- # (e.g. if it needs type or if groups and commands can share names).
579
- # For now, assuming name is sufficient for removal from the handler's flat list.
580
- return removed_cog
581
-
582
- def add_app_command(self, command: Union["AppCommand", "AppCommandGroup"]) -> None:
583
- """
584
- Adds a standalone application command or group to the bot.
585
- Use this for commands not defined within a Cog.
586
-
587
- Args:
588
- command (Union[AppCommand, AppCommandGroup]): The application command or group instance.
589
- This is typically the object returned by a decorator like @slash_command.
590
- """
591
- from .ext.app_commands.commands import (
592
- AppCommand,
593
- AppCommandGroup,
594
- ) # Ensure types
595
-
596
- if not isinstance(command, (AppCommand, AppCommandGroup)):
597
- raise TypeError(
598
- "Command must be an instance of AppCommand or AppCommandGroup."
599
- )
600
-
601
- # If it's a decorated function, the command object might be on __app_command_object__
602
- if hasattr(command, "__app_command_object__") and isinstance(
603
- getattr(command, "__app_command_object__"), (AppCommand, AppCommandGroup)
604
- ):
605
- actual_command_obj = getattr(command, "__app_command_object__")
606
- self.app_command_handler.add_command(actual_command_obj)
607
- print(
608
- f"Registered standalone app command/group '{actual_command_obj.name}'."
609
- )
610
- elif isinstance(
611
- command, (AppCommand, AppCommandGroup)
612
- ): # It's already the command object
613
- self.app_command_handler.add_command(command)
614
- print(f"Registered standalone app command/group '{command.name}'.")
615
- else:
616
- # This case should ideally not be hit if type checks are done by decorators
617
- print(
618
- f"Warning: Could not register app command {command}. It's not a recognized command object or decorated function."
619
- )
620
-
621
- async def on_command_error(
622
- self, ctx: "CommandContext", error: "CommandError"
623
- ) -> None:
624
- """
625
- Default command error handler. Called when a command raises an error.
626
- Users can override this method in a subclass of Client to implement custom error handling.
627
-
628
- Args:
629
- ctx (CommandContext): The context of the command that raised the error.
630
- error (CommandError): The error that was raised.
631
- """
632
- # Default behavior: print to console.
633
- # Users might want to send a message to ctx.channel or log to a file.
634
- print(
635
- f"Error in command '{ctx.command.name if ctx.command else 'unknown'}': {error}"
636
- )
637
-
638
- # Need to import CommandInvokeError for this check if not already globally available
639
- # For now, assuming it's imported via TYPE_CHECKING or directly if needed at runtime
640
- from .ext.commands.errors import (
641
- CommandInvokeError as CIE,
642
- ) # Local import for isinstance check
643
-
644
- if isinstance(error, CIE):
645
- # Now it's safe to access error.original
646
- print(
647
- f"Original exception: {type(error.original).__name__}: {error.original}"
648
- )
649
- # import traceback
650
- # traceback.print_exception(type(error.original), error.original, error.original.__traceback__)
651
-
652
- # --- Extension Management Methods ---
653
-
654
- def load_extension(self, name: str) -> ModuleType:
655
- """Load an extension by name using :mod:`disagreement.ext.loader`."""
656
-
657
- return ext_loader.load_extension(name)
658
-
659
- def unload_extension(self, name: str) -> None:
660
- """Unload a previously loaded extension."""
661
-
662
- ext_loader.unload_extension(name)
663
-
664
- def reload_extension(self, name: str) -> ModuleType:
665
- """Reload an extension by name."""
666
-
667
- return ext_loader.reload_extension(name)
668
-
669
- # --- Model Parsing and Fetching ---
670
-
671
- def parse_user(self, data: Dict[str, Any]) -> "User":
672
- """Parses user data and returns a User object, updating cache."""
673
- from .models import User # Ensure User model is available
674
-
675
- user = User(data)
676
- self._users[user.id] = user # Cache the user
677
- return user
678
-
679
- def parse_channel(self, data: Dict[str, Any]) -> "Channel":
680
- """Parses channel data and returns a Channel object, updating caches."""
681
-
682
- from .models import channel_factory
683
-
684
- channel = channel_factory(data, self)
685
- self._channels[channel.id] = channel
686
- if channel.guild_id:
687
- guild = self._guilds.get(channel.guild_id)
688
- if guild:
689
- guild._channels[channel.id] = channel
690
- return channel
691
-
692
- def parse_message(self, data: Dict[str, Any]) -> "Message":
693
- """Parses message data and returns a Message object, updating cache."""
694
-
695
- from .models import Message
696
-
697
- message = Message(data, client_instance=self)
698
- self._messages[message.id] = message
699
- return message
700
-
701
- def parse_webhook(self, data: Union[Dict[str, Any], "Webhook"]) -> "Webhook":
702
- """Parses webhook data and returns a Webhook object, updating cache."""
703
-
704
- from .models import Webhook
705
-
706
- if isinstance(data, Webhook):
707
- webhook = data
708
- webhook._client = self # type: ignore[attr-defined]
709
- else:
710
- webhook = Webhook(data, client_instance=self)
711
- self._webhooks[webhook.id] = webhook
712
- return webhook
713
-
714
- def parse_template(self, data: Dict[str, Any]) -> "GuildTemplate":
715
- """Parses template data into a GuildTemplate object."""
716
-
717
- from .models import GuildTemplate
718
-
719
- return GuildTemplate(data, client_instance=self)
720
-
721
- def parse_scheduled_event(self, data: Dict[str, Any]) -> "ScheduledEvent":
722
- """Parses scheduled event data and updates cache."""
723
-
724
- from .models import ScheduledEvent
725
-
726
- event = ScheduledEvent(data, client_instance=self)
727
- # Cache by ID under guild if guild cache exists
728
- guild = self._guilds.get(event.guild_id)
729
- if guild is not None:
730
- events = getattr(guild, "_scheduled_events", {})
731
- events[event.id] = event
732
- setattr(guild, "_scheduled_events", events)
733
- return event
734
-
735
- def parse_audit_log_entry(self, data: Dict[str, Any]) -> "AuditLogEntry":
736
- """Parses audit log entry data."""
737
- from .models import AuditLogEntry
738
-
739
- return AuditLogEntry(data, client_instance=self)
740
-
741
- def parse_invite(self, data: Dict[str, Any]) -> "Invite":
742
- """Parses invite data into an :class:`Invite`."""
743
-
744
- from .models import Invite
745
-
746
- return Invite.from_dict(data)
747
-
748
- async def fetch_user(self, user_id: Snowflake) -> Optional["User"]:
749
- """Fetches a user by ID from Discord."""
750
- if self._closed:
751
- raise DisagreementException("Client is closed.")
752
-
753
- cached_user = self._users.get(user_id)
754
- if cached_user:
755
- return cached_user # Return cached if available, though fetch implies wanting fresh
756
-
757
- try:
758
- user_data = await self._http.get_user(user_id)
759
- return self.parse_user(user_data)
760
- except DisagreementException as e: # Catch HTTP exceptions from http client
761
- print(f"Failed to fetch user {user_id}: {e}")
762
- return None
763
-
764
- async def fetch_message(
765
- self, channel_id: Snowflake, message_id: Snowflake
766
- ) -> Optional["Message"]:
767
- """Fetches a message by ID from Discord and caches it."""
768
-
769
- if self._closed:
770
- raise DisagreementException("Client is closed.")
771
-
772
- cached_message = self._messages.get(message_id)
773
- if cached_message:
774
- return cached_message
775
-
776
- try:
777
- message_data = await self._http.get_message(channel_id, message_id)
778
- return self.parse_message(message_data)
779
- except DisagreementException as e:
780
- print(
781
- f"Failed to fetch message {message_id} from channel {channel_id}: {e}"
782
- )
783
- return None
784
-
785
- def parse_member(self, data: Dict[str, Any], guild_id: Snowflake) -> "Member":
786
- """Parses member data and returns a Member object, updating relevant caches."""
787
- from .models import Member # Ensure Member model is available
788
-
789
- # Member's __init__ should handle the nested 'user' data.
790
- member = Member(data, client_instance=self)
791
- member.guild_id = str(guild_id)
792
-
793
- # Cache the member in the guild's member cache
794
- guild = self._guilds.get(guild_id)
795
- if guild:
796
- guild._members[member.id] = member # Assuming Guild has _members dict
797
-
798
- # Also cache the user part if not already cached or if this is newer
799
- # Since Member inherits from User, the member object itself is the user.
800
- self._users[member.id] = member
801
- # If 'user' was in data and Member.__init__ used it, it's already part of 'member'.
802
- return member
803
-
804
- async def fetch_member(
805
- self, guild_id: Snowflake, member_id: Snowflake
806
- ) -> Optional["Member"]:
807
- """Fetches a member from a guild by ID."""
808
- if self._closed:
809
- raise DisagreementException("Client is closed.")
810
-
811
- guild = self.get_guild(guild_id)
812
- if guild:
813
- cached_member = guild.get_member(member_id) # Use Guild's get_member
814
- if cached_member:
815
- return cached_member # Return cached if available
816
-
817
- try:
818
- member_data = await self._http.get_guild_member(guild_id, member_id)
819
- return self.parse_member(member_data, guild_id)
820
- except DisagreementException as e:
821
- print(f"Failed to fetch member {member_id} from guild {guild_id}: {e}")
822
- return None
823
-
824
- def parse_role(self, data: Dict[str, Any], guild_id: Snowflake) -> "Role":
825
- """Parses role data and returns a Role object, updating guild's role cache."""
826
- from .models import Role # Ensure Role model is available
827
-
828
- role = Role(data)
829
- guild = self._guilds.get(guild_id)
830
- if guild:
831
- # Update the role in the guild's roles list if it exists, or add it.
832
- # Guild.roles is List[Role]. We need to find and replace or append.
833
- found = False
834
- for i, existing_role in enumerate(guild.roles):
835
- if existing_role.id == role.id:
836
- guild.roles[i] = role
837
- found = True
838
- break
839
- if not found:
840
- guild.roles.append(role)
841
- return role
842
-
843
- def parse_guild(self, data: Dict[str, Any]) -> "Guild":
844
- """Parses guild data and returns a Guild object, updating cache."""
845
-
846
- from .models import Guild
847
-
848
- guild = Guild(data, client_instance=self)
849
- self._guilds[guild.id] = guild
850
-
851
- # Populate channel and member caches if provided
852
- for ch in data.get("channels", []):
853
- channel_obj = self.parse_channel(ch)
854
- guild._channels[channel_obj.id] = channel_obj
855
-
856
- for member in data.get("members", []):
857
- member_obj = self.parse_member(member, guild.id)
858
- guild._members[member_obj.id] = member_obj
859
-
860
- return guild
861
-
862
- async def fetch_roles(self, guild_id: Snowflake) -> List["Role"]:
863
- """Fetches all roles for a given guild and caches them.
864
-
865
- If the guild is not cached, it will be retrieved first using
866
- :meth:`fetch_guild`.
867
- """
868
- if self._closed:
869
- raise DisagreementException("Client is closed.")
870
- guild = self.get_guild(guild_id)
871
- if not guild:
872
- guild = await self.fetch_guild(guild_id)
873
- if not guild:
874
- return []
875
-
876
- try:
877
- roles_data = await self._http.get_guild_roles(guild_id)
878
- parsed_roles = []
879
- for role_data in roles_data:
880
- # parse_role will add/update it in the guild.roles list
881
- parsed_roles.append(self.parse_role(role_data, guild_id))
882
- guild.roles = parsed_roles # Replace the entire list with the fresh one
883
- return parsed_roles
884
- except DisagreementException as e:
885
- print(f"Failed to fetch roles for guild {guild_id}: {e}")
886
- return []
887
-
888
- async def fetch_role(
889
- self, guild_id: Snowflake, role_id: Snowflake
890
- ) -> Optional["Role"]:
891
- """Fetches a specific role from a guild by ID.
892
- If roles for the guild aren't cached or might be stale, it fetches all roles first.
893
- """
894
- guild = self.get_guild(guild_id)
895
- if guild:
896
- # Try to find in existing guild.roles
897
- for role in guild.roles:
898
- if role.id == role_id:
899
- return role
900
-
901
- # If not found in cache or guild doesn't exist yet in cache, fetch all roles for the guild
902
- await self.fetch_roles(guild_id) # This will populate/update guild.roles
903
-
904
- # Try again from the now (hopefully) populated cache
905
- guild = self.get_guild(
906
- guild_id
907
- ) # Re-get guild in case it was populated by fetch_roles
908
- if guild:
909
- for role in guild.roles:
910
- if role.id == role_id:
911
- return role
912
-
913
- return None # Role not found even after fetching
914
-
915
- # --- API Methods ---
916
-
917
- # --- API Methods ---
918
-
919
- async def send_message(
920
- self,
921
- channel_id: str,
922
- content: Optional[str] = None,
923
- *, # Make additional params keyword-only
924
- tts: bool = False,
925
- embed: Optional["Embed"] = None,
926
- embeds: Optional[List["Embed"]] = None,
927
- components: Optional[List["ActionRow"]] = None,
928
- allowed_mentions: Optional[Dict[str, Any]] = None,
929
- message_reference: Optional[Dict[str, Any]] = None,
930
- attachments: Optional[List[Any]] = None,
931
- files: Optional[List[Any]] = None,
932
- flags: Optional[int] = None,
933
- view: Optional["View"] = None,
934
- ) -> "Message":
935
- """|coro|
936
- Sends a message to the specified channel.
937
-
938
- Args:
939
- channel_id (str): The ID of the channel to send the message to.
940
- content (Optional[str]): The content of the message.
941
- tts (bool): Whether the message should be sent with text-to-speech. Defaults to False.
942
- embed (Optional[Embed]): A single embed to send. Cannot be used with `embeds`.
943
- embeds (Optional[List[Embed]]): A list of embeds to send. Cannot be used with `embed`.
944
- Discord supports up to 10 embeds per message.
945
- components (Optional[List[ActionRow]]): A list of ActionRow components to include.
946
- allowed_mentions (Optional[Dict[str, Any]]): Allowed mentions for the message.
947
- message_reference (Optional[Dict[str, Any]]): Message reference for replying.
948
- attachments (Optional[List[Any]]): Attachments to include with the message.
949
- files (Optional[List[Any]]): Files to upload with the message.
950
- flags (Optional[int]): Message flags.
951
- view (Optional[View]): A view to send with the message.
952
-
953
- Returns:
954
- Message: The message that was sent.
955
-
956
- Raises:
957
- HTTPException: Sending the message failed.
958
- ValueError: If both `embed` and `embeds` are provided, or if both `components` and `view` are provided.
959
- """
960
- if self._closed:
961
- raise DisagreementException("Client is closed.")
962
-
963
- if embed and embeds:
964
- raise ValueError("Cannot provide both embed and embeds.")
965
- if components and view:
966
- raise ValueError("Cannot provide both 'components' and 'view'.")
967
-
968
- final_embeds_payload: Optional[List[Dict[str, Any]]] = None
969
- if embed:
970
- final_embeds_payload = [embed.to_dict()]
971
- elif embeds:
972
- from .models import (
973
- Embed as EmbedModel,
974
- )
975
-
976
- final_embeds_payload = [
977
- e.to_dict() for e in embeds if isinstance(e, EmbedModel)
978
- ]
979
-
980
- components_payload: Optional[List[Dict[str, Any]]] = None
981
- if view:
982
- await view._start(self)
983
- components_payload = view.to_components_payload()
984
- elif components:
985
- from .models import Component as ComponentModel
986
-
987
- components_payload = [
988
- comp.to_dict()
989
- for comp in components
990
- if isinstance(comp, ComponentModel)
991
- ]
992
-
993
- message_data = await self._http.send_message(
994
- channel_id=channel_id,
995
- content=content,
996
- tts=tts,
997
- embeds=final_embeds_payload,
998
- components=components_payload,
999
- allowed_mentions=allowed_mentions,
1000
- message_reference=message_reference,
1001
- attachments=attachments,
1002
- files=files,
1003
- flags=flags,
1004
- )
1005
-
1006
- if view:
1007
- message_id = message_data["id"]
1008
- view.message_id = message_id
1009
- self._views[message_id] = view
1010
-
1011
- return self.parse_message(message_data)
1012
-
1013
- def typing(self, channel_id: str) -> Typing:
1014
- """Return a context manager to show a typing indicator in a channel."""
1015
-
1016
- return Typing(self, channel_id)
1017
-
1018
- async def join_voice(
1019
- self,
1020
- guild_id: Snowflake,
1021
- channel_id: Snowflake,
1022
- *,
1023
- self_mute: bool = False,
1024
- self_deaf: bool = False,
1025
- ) -> VoiceClient:
1026
- """|coro| Join a voice channel and return a :class:`VoiceClient`."""
1027
-
1028
- if self._closed:
1029
- raise DisagreementException("Client is closed.")
1030
- if not self.is_ready():
1031
- await self.wait_until_ready()
1032
- if self._gateway is None:
1033
- raise DisagreementException("Gateway is not connected.")
1034
- if not self.user:
1035
- raise DisagreementException("Client user unavailable.")
1036
- assert self.user is not None
1037
- user_id = self.user.id
1038
-
1039
- if guild_id in self._voice_clients:
1040
- return self._voice_clients[guild_id]
1041
-
1042
- payload = {
1043
- "op": GatewayOpcode.VOICE_STATE_UPDATE,
1044
- "d": {
1045
- "guild_id": str(guild_id),
1046
- "channel_id": str(channel_id),
1047
- "self_mute": self_mute,
1048
- "self_deaf": self_deaf,
1049
- },
1050
- }
1051
- await self._gateway._send_json(payload) # type: ignore[attr-defined]
1052
-
1053
- server = await self.wait_for(
1054
- "VOICE_SERVER_UPDATE",
1055
- check=lambda d: d.get("guild_id") == str(guild_id),
1056
- timeout=10,
1057
- )
1058
- state = await self.wait_for(
1059
- "VOICE_STATE_UPDATE",
1060
- check=lambda d, uid=user_id: d.get("guild_id") == str(guild_id)
1061
- and d.get("user_id") == str(uid),
1062
- timeout=10,
1063
- )
1064
-
1065
- endpoint = f"wss://{server['endpoint']}?v=10"
1066
- token = server["token"]
1067
- session_id = state["session_id"]
1068
-
1069
- voice = VoiceClient(
1070
- endpoint,
1071
- session_id,
1072
- token,
1073
- int(guild_id),
1074
- int(self.user.id),
1075
- verbose=self.verbose,
1076
- )
1077
- await voice.connect()
1078
- self._voice_clients[guild_id] = voice
1079
- return voice
1080
-
1081
- async def add_reaction(self, channel_id: str, message_id: str, emoji: str) -> None:
1082
- """|coro| Add a reaction to a message."""
1083
-
1084
- await self.create_reaction(channel_id, message_id, emoji)
1085
-
1086
- async def remove_reaction(
1087
- self, channel_id: str, message_id: str, emoji: str
1088
- ) -> None:
1089
- """|coro| Remove the bot's reaction from a message."""
1090
-
1091
- await self.delete_reaction(channel_id, message_id, emoji)
1092
-
1093
- async def clear_reactions(self, channel_id: str, message_id: str) -> None:
1094
- """|coro| Remove all reactions from a message."""
1095
-
1096
- if self._closed:
1097
- raise DisagreementException("Client is closed.")
1098
-
1099
- await self._http.clear_reactions(channel_id, message_id)
1100
-
1101
- async def create_reaction(
1102
- self, channel_id: str, message_id: str, emoji: str
1103
- ) -> None:
1104
- """|coro| Add a reaction to a message."""
1105
-
1106
- if self._closed:
1107
- raise DisagreementException("Client is closed.")
1108
-
1109
- await self._http.create_reaction(channel_id, message_id, emoji)
1110
-
1111
- user_id = getattr(getattr(self, "user", None), "id", None)
1112
- payload = {
1113
- "user_id": user_id,
1114
- "channel_id": channel_id,
1115
- "message_id": message_id,
1116
- "emoji": {"name": emoji, "id": None},
1117
- }
1118
- if hasattr(self, "_event_dispatcher"):
1119
- await self._event_dispatcher.dispatch("MESSAGE_REACTION_ADD", payload)
1120
-
1121
- async def delete_reaction(
1122
- self, channel_id: str, message_id: str, emoji: str
1123
- ) -> None:
1124
- """|coro| Remove the bot's reaction from a message."""
1125
-
1126
- if self._closed:
1127
- raise DisagreementException("Client is closed.")
1128
-
1129
- await self._http.delete_reaction(channel_id, message_id, emoji)
1130
-
1131
- user_id = getattr(getattr(self, "user", None), "id", None)
1132
- payload = {
1133
- "user_id": user_id,
1134
- "channel_id": channel_id,
1135
- "message_id": message_id,
1136
- "emoji": {"name": emoji, "id": None},
1137
- }
1138
- if hasattr(self, "_event_dispatcher"):
1139
- await self._event_dispatcher.dispatch("MESSAGE_REACTION_REMOVE", payload)
1140
-
1141
- async def get_reactions(
1142
- self, channel_id: str, message_id: str, emoji: str
1143
- ) -> List["User"]:
1144
- """|coro| Return the users who reacted with the given emoji."""
1145
-
1146
- if self._closed:
1147
- raise DisagreementException("Client is closed.")
1148
-
1149
- users_data = await self._http.get_reactions(channel_id, message_id, emoji)
1150
- return [self.parse_user(u) for u in users_data]
1151
-
1152
- async def edit_message(
1153
- self,
1154
- channel_id: str,
1155
- message_id: str,
1156
- *,
1157
- content: Optional[str] = None,
1158
- embed: Optional["Embed"] = None,
1159
- embeds: Optional[List["Embed"]] = None,
1160
- components: Optional[List["ActionRow"]] = None,
1161
- allowed_mentions: Optional[Dict[str, Any]] = None,
1162
- flags: Optional[int] = None,
1163
- view: Optional["View"] = None,
1164
- ) -> "Message":
1165
- """Edits a previously sent message."""
1166
-
1167
- if self._closed:
1168
- raise DisagreementException("Client is closed.")
1169
-
1170
- if embed and embeds:
1171
- raise ValueError("Cannot provide both embed and embeds.")
1172
- if components and view:
1173
- raise ValueError("Cannot provide both 'components' and 'view'.")
1174
-
1175
- final_embeds_payload: Optional[List[Dict[str, Any]]] = None
1176
- if embed:
1177
- final_embeds_payload = [embed.to_dict()]
1178
- elif embeds:
1179
- final_embeds_payload = [e.to_dict() for e in embeds]
1180
-
1181
- components_payload: Optional[List[Dict[str, Any]]] = None
1182
- if view:
1183
- await view._start(self)
1184
- components_payload = view.to_components_payload()
1185
- elif components:
1186
- components_payload = [c.to_dict() for c in components]
1187
-
1188
- payload: Dict[str, Any] = {}
1189
- if content is not None:
1190
- payload["content"] = content
1191
- if final_embeds_payload is not None:
1192
- payload["embeds"] = final_embeds_payload
1193
- if components_payload is not None:
1194
- payload["components"] = components_payload
1195
- if allowed_mentions is not None:
1196
- payload["allowed_mentions"] = allowed_mentions
1197
- if flags is not None:
1198
- payload["flags"] = flags
1199
-
1200
- message_data = await self._http.edit_message(
1201
- channel_id=channel_id,
1202
- message_id=message_id,
1203
- payload=payload,
1204
- )
1205
-
1206
- if view:
1207
- view.message_id = message_data["id"]
1208
- self._views[message_data["id"]] = view
1209
-
1210
- return self.parse_message(message_data)
1211
-
1212
- def get_guild(self, guild_id: Snowflake) -> Optional["Guild"]:
1213
- """Returns a guild from the internal cache.
1214
-
1215
- Use :meth:`fetch_guild` to retrieve it from Discord if it's not cached.
1216
- """
1217
-
1218
- return self._guilds.get(guild_id)
1219
-
1220
- def get_channel(self, channel_id: Snowflake) -> Optional["Channel"]:
1221
- """Returns a channel from the internal cache."""
1222
-
1223
- return self._channels.get(channel_id)
1224
-
1225
- def get_message(self, message_id: Snowflake) -> Optional["Message"]:
1226
- """Returns a message from the internal cache."""
1227
-
1228
- return self._messages.get(message_id)
1229
-
1230
- async def fetch_guild(self, guild_id: Snowflake) -> Optional["Guild"]:
1231
- """Fetches a guild by ID from Discord and caches it."""
1232
-
1233
- if self._closed:
1234
- raise DisagreementException("Client is closed.")
1235
-
1236
- cached_guild = self._guilds.get(guild_id)
1237
- if cached_guild:
1238
- return cached_guild
1239
-
1240
- try:
1241
- guild_data = await self._http.get_guild(guild_id)
1242
- return self.parse_guild(guild_data)
1243
- except DisagreementException as e:
1244
- print(f"Failed to fetch guild {guild_id}: {e}")
1245
- return None
1246
-
1247
- async def fetch_channel(self, channel_id: Snowflake) -> Optional["Channel"]:
1248
- """Fetches a channel from Discord by its ID and updates the cache."""
1249
-
1250
- if self._closed:
1251
- raise DisagreementException("Client is closed.")
1252
-
1253
- try:
1254
- channel_data = await self._http.get_channel(channel_id)
1255
- if not channel_data:
1256
- return None
1257
-
1258
- from .models import channel_factory
1259
-
1260
- channel = channel_factory(channel_data, self)
1261
-
1262
- self._channels[channel.id] = channel
1263
- return channel
1264
-
1265
- except DisagreementException as e: # Includes HTTPException
1266
- print(f"Failed to fetch channel {channel_id}: {e}")
1267
- return None
1268
-
1269
- async def fetch_audit_logs(
1270
- self, guild_id: Snowflake, **filters: Any
1271
- ) -> AsyncIterator["AuditLogEntry"]:
1272
- """Fetch audit log entries for a guild."""
1273
- if self._closed:
1274
- raise DisagreementException("Client is closed.")
1275
-
1276
- data = await self._http.get_audit_logs(guild_id, **filters)
1277
- for entry in data.get("audit_log_entries", []):
1278
- yield self.parse_audit_log_entry(entry)
1279
-
1280
- async def fetch_voice_regions(self) -> List[VoiceRegion]:
1281
- """Fetches available voice regions."""
1282
-
1283
- if self._closed:
1284
- raise DisagreementException("Client is closed.")
1285
-
1286
- data = await self._http.get_voice_regions()
1287
- regions = []
1288
- for region in data:
1289
- region_id = region.get("id")
1290
- if region_id:
1291
- regions.append(VoiceRegion(region_id))
1292
- return regions
1293
-
1294
- async def create_webhook(
1295
- self, channel_id: Snowflake, payload: Dict[str, Any]
1296
- ) -> "Webhook":
1297
- """|coro| Create a webhook in the given channel."""
1298
-
1299
- if self._closed:
1300
- raise DisagreementException("Client is closed.")
1301
-
1302
- data = await self._http.create_webhook(channel_id, payload)
1303
- return self.parse_webhook(data)
1304
-
1305
- async def edit_webhook(
1306
- self, webhook_id: Snowflake, payload: Dict[str, Any]
1307
- ) -> "Webhook":
1308
- """|coro| Edit an existing webhook."""
1309
-
1310
- if self._closed:
1311
- raise DisagreementException("Client is closed.")
1312
-
1313
- data = await self._http.edit_webhook(webhook_id, payload)
1314
- return self.parse_webhook(data)
1315
-
1316
- async def delete_webhook(self, webhook_id: Snowflake) -> None:
1317
- """|coro| Delete a webhook by ID."""
1318
-
1319
- if self._closed:
1320
- raise DisagreementException("Client is closed.")
1321
-
1322
- await self._http.delete_webhook(webhook_id)
1323
-
1324
- async def fetch_templates(self, guild_id: Snowflake) -> List["GuildTemplate"]:
1325
- """|coro| Fetch all templates for a guild."""
1326
-
1327
- if self._closed:
1328
- raise DisagreementException("Client is closed.")
1329
-
1330
- data = await self._http.get_guild_templates(guild_id)
1331
- return [self.parse_template(t) for t in data]
1332
-
1333
- async def create_template(
1334
- self, guild_id: Snowflake, payload: Dict[str, Any]
1335
- ) -> "GuildTemplate":
1336
- """|coro| Create a template for a guild."""
1337
-
1338
- if self._closed:
1339
- raise DisagreementException("Client is closed.")
1340
-
1341
- data = await self._http.create_guild_template(guild_id, payload)
1342
- return self.parse_template(data)
1343
-
1344
- async def sync_template(
1345
- self, guild_id: Snowflake, template_code: str
1346
- ) -> "GuildTemplate":
1347
- """|coro| Sync a template to the guild's current state."""
1348
-
1349
- if self._closed:
1350
- raise DisagreementException("Client is closed.")
1351
-
1352
- data = await self._http.sync_guild_template(guild_id, template_code)
1353
- return self.parse_template(data)
1354
-
1355
- async def delete_template(self, guild_id: Snowflake, template_code: str) -> None:
1356
- """|coro| Delete a guild template."""
1357
-
1358
- if self._closed:
1359
- raise DisagreementException("Client is closed.")
1360
-
1361
- await self._http.delete_guild_template(guild_id, template_code)
1362
-
1363
- async def fetch_scheduled_events(
1364
- self, guild_id: Snowflake
1365
- ) -> List["ScheduledEvent"]:
1366
- """|coro| Fetch all scheduled events for a guild."""
1367
-
1368
- if self._closed:
1369
- raise DisagreementException("Client is closed.")
1370
-
1371
- data = await self._http.get_guild_scheduled_events(guild_id)
1372
- return [self.parse_scheduled_event(ev) for ev in data]
1373
-
1374
- async def fetch_scheduled_event(
1375
- self, guild_id: Snowflake, event_id: Snowflake
1376
- ) -> Optional["ScheduledEvent"]:
1377
- """|coro| Fetch a single scheduled event."""
1378
-
1379
- if self._closed:
1380
- raise DisagreementException("Client is closed.")
1381
-
1382
- try:
1383
- data = await self._http.get_guild_scheduled_event(guild_id, event_id)
1384
- return self.parse_scheduled_event(data)
1385
- except DisagreementException as e:
1386
- print(f"Failed to fetch scheduled event {event_id}: {e}")
1387
- return None
1388
-
1389
- async def create_scheduled_event(
1390
- self, guild_id: Snowflake, payload: Dict[str, Any]
1391
- ) -> "ScheduledEvent":
1392
- """|coro| Create a scheduled event in a guild."""
1393
-
1394
- if self._closed:
1395
- raise DisagreementException("Client is closed.")
1396
-
1397
- data = await self._http.create_guild_scheduled_event(guild_id, payload)
1398
- return self.parse_scheduled_event(data)
1399
-
1400
- async def edit_scheduled_event(
1401
- self, guild_id: Snowflake, event_id: Snowflake, payload: Dict[str, Any]
1402
- ) -> "ScheduledEvent":
1403
- """|coro| Edit an existing scheduled event."""
1404
-
1405
- if self._closed:
1406
- raise DisagreementException("Client is closed.")
1407
-
1408
- data = await self._http.edit_guild_scheduled_event(guild_id, event_id, payload)
1409
- return self.parse_scheduled_event(data)
1410
-
1411
- async def delete_scheduled_event(
1412
- self, guild_id: Snowflake, event_id: Snowflake
1413
- ) -> None:
1414
- """|coro| Delete a scheduled event."""
1415
-
1416
- if self._closed:
1417
- raise DisagreementException("Client is closed.")
1418
-
1419
- await self._http.delete_guild_scheduled_event(guild_id, event_id)
1420
-
1421
- async def create_invite(
1422
- self, channel_id: Snowflake, payload: Dict[str, Any]
1423
- ) -> "Invite":
1424
- """|coro| Create an invite for the given channel."""
1425
-
1426
- if self._closed:
1427
- raise DisagreementException("Client is closed.")
1428
-
1429
- return await self._http.create_invite(channel_id, payload)
1430
-
1431
- async def delete_invite(self, code: str) -> None:
1432
- """|coro| Delete an invite by code."""
1433
-
1434
- if self._closed:
1435
- raise DisagreementException("Client is closed.")
1436
-
1437
- await self._http.delete_invite(code)
1438
-
1439
- async def fetch_invites(self, channel_id: Snowflake) -> List["Invite"]:
1440
- """|coro| Fetch all invites for a channel."""
1441
-
1442
- if self._closed:
1443
- raise DisagreementException("Client is closed.")
1444
-
1445
- data = await self._http.get_channel_invites(channel_id)
1446
- return [self.parse_invite(inv) for inv in data]
1447
-
1448
- # --- Application Command Methods ---
1449
- async def process_interaction(self, interaction: Interaction) -> None:
1450
- """Internal method to process an interaction from the gateway."""
1451
-
1452
- if hasattr(self, "on_interaction_create"):
1453
- asyncio.create_task(self.on_interaction_create(interaction))
1454
- # Route component interactions to the appropriate View
1455
- if (
1456
- interaction.type == InteractionType.MESSAGE_COMPONENT
1457
- and interaction.message
1458
- ):
1459
- view = self._views.get(interaction.message.id)
1460
- if view:
1461
- asyncio.create_task(view._dispatch(interaction))
1462
- return
1463
-
1464
- await self.app_command_handler.process_interaction(interaction)
1465
-
1466
- async def sync_application_commands(
1467
- self, guild_id: Optional[Snowflake] = None
1468
- ) -> None:
1469
- """Synchronizes application commands with Discord."""
1470
-
1471
- if not self.application_id:
1472
- print(
1473
- "Warning: Cannot sync application commands, application_id is not set. "
1474
- "Ensure the client is connected and READY."
1475
- )
1476
- return
1477
- if not self.is_ready():
1478
- print(
1479
- "Warning: Client is not ready. Waiting for client to be ready before syncing commands."
1480
- )
1481
- await self.wait_until_ready()
1482
- if not self.application_id:
1483
- print(
1484
- "Error: application_id still not set after client is ready. Cannot sync commands."
1485
- )
1486
- return
1487
-
1488
- await self.app_command_handler.sync_commands(
1489
- application_id=self.application_id, guild_id=guild_id
1490
- )
1491
-
1492
- async def on_interaction_create(self, interaction: Interaction) -> None:
1493
- """|coro| Called when an interaction is created."""
1494
-
1495
- pass
1496
-
1497
- async def on_presence_update(self, presence) -> None:
1498
- """|coro| Called when a user's presence is updated."""
1499
-
1500
- pass
1501
-
1502
- async def on_typing_start(self, typing) -> None:
1503
- """|coro| Called when a user starts typing in a channel."""
1504
-
1505
- pass
1506
-
1507
- async def on_app_command_error(
1508
- self, context: AppCommandContext, error: Exception
1509
- ) -> None:
1510
- """Default error handler for application commands."""
1511
-
1512
- print(
1513
- f"Error in application command '{context.command.name if context.command else 'unknown'}': {error}"
1514
- )
1515
- try:
1516
- if not context._responded:
1517
- await context.send(
1518
- "An error occurred while running this command.", ephemeral=True
1519
- )
1520
- except Exception as e:
1521
- print(f"Failed to send error message for app command: {e}")
1522
-
1523
- async def on_error(
1524
- self, event_method: str, exc: Exception, *args: Any, **kwargs: Any
1525
- ) -> None:
1526
- """Default event listener error handler."""
1527
-
1528
- print(f"Unhandled exception in event listener for '{event_method}':")
1529
- print(f"{type(exc).__name__}: {exc}")
1530
-
1531
-
1532
- class AutoShardedClient(Client):
1533
- """A :class:`Client` that automatically determines the shard count.
1534
-
1535
- If ``shard_count`` is not provided, the client will query the Discord API
1536
- via :meth:`HTTPClient.get_gateway_bot` for the recommended shard count and
1537
- use that when connecting.
1538
- """
1539
-
1540
- async def connect(self, reconnect: bool = True) -> None: # type: ignore[override]
1541
- if self.shard_count is None:
1542
- data = await self._http.get_gateway_bot()
1543
- self.shard_count = data.get("shards", 1)
1544
-
1545
- await super().connect(reconnect=reconnect)
1
+ # disagreement/client.py
2
+
3
+ """
4
+ The main Client class for interacting with the Discord API.
5
+ """
6
+
7
+ import asyncio
8
+ import signal
9
+ from typing import (
10
+ Optional,
11
+ Callable,
12
+ Any,
13
+ TYPE_CHECKING,
14
+ Awaitable,
15
+ AsyncIterator,
16
+ Union,
17
+ List,
18
+ Dict,
19
+ )
20
+ from types import ModuleType
21
+
22
+ from .http import HTTPClient
23
+ from .gateway import GatewayClient
24
+ from .shard_manager import ShardManager
25
+ from .event_dispatcher import EventDispatcher
26
+ from .enums import GatewayIntent, InteractionType, GatewayOpcode, VoiceRegion
27
+ from .errors import DisagreementException, AuthenticationError
28
+ from .typing import Typing
29
+ from .caching import MemberCacheFlags
30
+ from .cache import Cache, GuildCache, ChannelCache, MemberCache
31
+ from .ext.commands.core import Command, CommandHandler, Group
32
+ from .ext.commands.cog import Cog
33
+ from .ext.app_commands.handler import AppCommandHandler
34
+ from .ext.app_commands.context import AppCommandContext
35
+ from .ext import loader as ext_loader
36
+ from .interactions import Interaction, Snowflake
37
+ from .error_handler import setup_global_error_handler
38
+ from .voice_client import VoiceClient
39
+
40
+ if TYPE_CHECKING:
41
+ from .models import (
42
+ Message,
43
+ Embed,
44
+ ActionRow,
45
+ Guild,
46
+ Channel,
47
+ User,
48
+ Member,
49
+ Role,
50
+ TextChannel,
51
+ VoiceChannel,
52
+ CategoryChannel,
53
+ Thread,
54
+ DMChannel,
55
+ Webhook,
56
+ GuildTemplate,
57
+ ScheduledEvent,
58
+ AuditLogEntry,
59
+ Invite,
60
+ )
61
+ from .ui.view import View
62
+ from .enums import ChannelType as EnumChannelType
63
+ from .ext.commands.core import CommandContext
64
+ from .ext.commands.errors import CommandError, CommandInvokeError
65
+ from .ext.app_commands.commands import AppCommand, AppCommandGroup
66
+
67
+
68
+ class Client:
69
+ """
70
+ Represents a client connection that connects to Discord.
71
+ This class is used to interact with the Discord WebSocket and API.
72
+
73
+ Args:
74
+ token (str): The bot token for authentication.
75
+ intents (Optional[int]): The Gateway Intents to use. Defaults to `GatewayIntent.default()`.
76
+ You might need to enable privileged intents in your bot's application page.
77
+ loop (Optional[asyncio.AbstractEventLoop]): The event loop to use for asynchronous operations.
78
+ Defaults to `asyncio.get_event_loop()`.
79
+ command_prefix (Union[str, List[str], Callable[['Client', Message], Union[str, List[str]]]]):
80
+ The prefix(es) for commands. Defaults to '!'.
81
+ verbose (bool): If True, print raw HTTP and Gateway traffic for debugging.
82
+ http_options (Optional[Dict[str, Any]]): Extra options passed to
83
+ :class:`HTTPClient` for creating the internal
84
+ :class:`aiohttp.ClientSession`.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ token: str,
90
+ intents: Optional[int] = None,
91
+ loop: Optional[asyncio.AbstractEventLoop] = None,
92
+ command_prefix: Union[
93
+ str, List[str], Callable[["Client", "Message"], Union[str, List[str]]]
94
+ ] = "!",
95
+ application_id: Optional[Union[str, int]] = None,
96
+ verbose: bool = False,
97
+ mention_replies: bool = False,
98
+ shard_count: Optional[int] = None,
99
+ gateway_max_retries: int = 5,
100
+ gateway_max_backoff: float = 60.0,
101
+ member_cache_flags: Optional[MemberCacheFlags] = None,
102
+ http_options: Optional[Dict[str, Any]] = None,
103
+ ):
104
+ if not token:
105
+ raise ValueError("A bot token must be provided.")
106
+
107
+ self.token: str = token
108
+ self.member_cache_flags: MemberCacheFlags = (
109
+ member_cache_flags if member_cache_flags is not None else MemberCacheFlags()
110
+ )
111
+ self.intents: int = intents if intents is not None else GatewayIntent.default()
112
+ if loop:
113
+ self.loop: asyncio.AbstractEventLoop = loop
114
+ else:
115
+ try:
116
+ self.loop = asyncio.get_running_loop()
117
+ except RuntimeError:
118
+ self.loop = asyncio.new_event_loop()
119
+ asyncio.set_event_loop(self.loop)
120
+ self.application_id: Optional[Snowflake] = (
121
+ str(application_id) if application_id else None
122
+ )
123
+ setup_global_error_handler(self.loop)
124
+
125
+ self.verbose: bool = verbose
126
+ self._http: HTTPClient = HTTPClient(
127
+ token=self.token,
128
+ verbose=verbose,
129
+ **(http_options or {}),
130
+ )
131
+ self._event_dispatcher: EventDispatcher = EventDispatcher(client_instance=self)
132
+ self._gateway: Optional[GatewayClient] = (
133
+ None # Initialized in run() or connect()
134
+ )
135
+ self.shard_count: Optional[int] = shard_count
136
+ self.gateway_max_retries: int = gateway_max_retries
137
+ self.gateway_max_backoff: float = gateway_max_backoff
138
+ self._shard_manager: Optional[ShardManager] = None
139
+
140
+ # Initialize CommandHandler
141
+ self.command_handler: CommandHandler = CommandHandler(
142
+ client=self, prefix=command_prefix
143
+ )
144
+ self.app_command_handler: AppCommandHandler = AppCommandHandler(client=self)
145
+ # Register internal listener for processing commands from messages
146
+ self._event_dispatcher.register(
147
+ "MESSAGE_CREATE", self._process_message_for_commands
148
+ )
149
+
150
+ self._closed: bool = False
151
+ self._ready_event: asyncio.Event = asyncio.Event()
152
+ self.user: Optional["User"] = (
153
+ None # The bot's own user object, populated on READY
154
+ )
155
+
156
+ # Internal Caches
157
+ self._guilds: GuildCache = GuildCache()
158
+ self._channels: ChannelCache = ChannelCache()
159
+ self._users: Cache["User"] = Cache()
160
+ self._messages: Cache["Message"] = Cache(ttl=3600) # Cache messages for an hour
161
+ self._views: Dict[Snowflake, "View"] = {}
162
+ self._persistent_views: Dict[str, "View"] = {}
163
+ self._voice_clients: Dict[Snowflake, VoiceClient] = {}
164
+ self._webhooks: Dict[Snowflake, "Webhook"] = {}
165
+
166
+ # Default whether replies mention the user
167
+ self.mention_replies: bool = mention_replies
168
+
169
+ # Basic signal handling for graceful shutdown
170
+ # This might be better handled by the user's application code, but can be a nice default.
171
+ # For more robust handling, consider libraries or more advanced patterns.
172
+ try:
173
+ self.loop.add_signal_handler(
174
+ signal.SIGINT, lambda: self.loop.create_task(self.close())
175
+ )
176
+ self.loop.add_signal_handler(
177
+ signal.SIGTERM, lambda: self.loop.create_task(self.close())
178
+ )
179
+ except NotImplementedError:
180
+ # add_signal_handler is not available on all platforms (e.g., Windows default event loop policy)
181
+ # Users on these platforms would need to handle shutdown differently.
182
+ print(
183
+ "Warning: Signal handlers for SIGINT/SIGTERM could not be added. "
184
+ "Graceful shutdown via signals might not work as expected on this platform."
185
+ )
186
+
187
+ async def _initialize_gateway(self):
188
+ """Initializes the GatewayClient if it doesn't exist."""
189
+ if self._gateway is None:
190
+ self._gateway = GatewayClient(
191
+ http_client=self._http,
192
+ event_dispatcher=self._event_dispatcher,
193
+ token=self.token,
194
+ intents=self.intents,
195
+ client_instance=self,
196
+ verbose=self.verbose,
197
+ max_retries=self.gateway_max_retries,
198
+ max_backoff=self.gateway_max_backoff,
199
+ )
200
+
201
+ async def _initialize_shard_manager(self) -> None:
202
+ """Initializes the :class:`ShardManager` if not already created."""
203
+ if self._shard_manager is None:
204
+ count = self.shard_count or 1
205
+ self._shard_manager = ShardManager(self, count)
206
+
207
+ async def connect(self, reconnect: bool = True) -> None:
208
+ """
209
+ Establishes a connection to Discord. This includes logging in and connecting to the Gateway.
210
+ This method is a coroutine.
211
+
212
+ Args:
213
+ reconnect (bool): Whether to automatically attempt to reconnect on disconnect.
214
+ (Note: Basic reconnect logic is within GatewayClient for now)
215
+
216
+ Raises:
217
+ GatewayException: If the connection to the gateway fails.
218
+ AuthenticationError: If the token is invalid.
219
+ """
220
+ if self._closed:
221
+ raise DisagreementException("Client is closed and cannot connect.")
222
+ if self.shard_count and self.shard_count > 1:
223
+ await self._initialize_shard_manager()
224
+ assert self._shard_manager is not None
225
+ await self._shard_manager.start()
226
+ print(
227
+ f"Client connected using {self.shard_count} shards, waiting for READY signal..."
228
+ )
229
+ await self.wait_until_ready()
230
+ print("Client is READY!")
231
+ return
232
+
233
+ await self._initialize_gateway()
234
+ assert self._gateway is not None # Should be initialized by now
235
+
236
+ retry_delay = 5 # seconds
237
+ max_retries = 5 # For initial connection attempts by Client.run, Gateway has its own internal retries for some cases.
238
+
239
+ for attempt in range(max_retries):
240
+ try:
241
+ await self._gateway.connect()
242
+ # After successful connection, GatewayClient's HELLO handler will trigger IDENTIFY/RESUME
243
+ # and its READY handler will set self._ready_event via dispatcher.
244
+ print("Client connected to Gateway, waiting for READY signal...")
245
+ await self.wait_until_ready() # Wait for the READY event from Gateway
246
+ print("Client is READY!")
247
+ return # Successfully connected and ready
248
+ except AuthenticationError: # Non-recoverable by retry here
249
+ print("Authentication failed. Please check your bot token.")
250
+ await self.close() # Ensure cleanup
251
+ raise
252
+ except DisagreementException as e: # Includes GatewayException
253
+ print(f"Failed to connect (Attempt {attempt + 1}/{max_retries}): {e}")
254
+ if attempt < max_retries - 1:
255
+ print(f"Retrying in {retry_delay} seconds...")
256
+ await asyncio.sleep(retry_delay)
257
+ retry_delay = min(
258
+ retry_delay * 2, 60
259
+ ) # Exponential backoff up to 60s
260
+ else:
261
+ print("Max connection retries reached. Giving up.")
262
+ await self.close() # Ensure cleanup
263
+ raise
264
+ # Should not be reached if max_retries is > 0
265
+ if max_retries == 0: # If max_retries was 0, means no retries attempted
266
+ raise DisagreementException("Connection failed with 0 retries allowed.")
267
+
268
+ async def run(self) -> None:
269
+ """
270
+ A blocking call that connects the client to Discord and runs until the client is closed.
271
+ This method is a coroutine.
272
+ It handles login, Gateway connection, and keeping the connection alive.
273
+ """
274
+ if self._closed:
275
+ raise DisagreementException("Client is already closed.")
276
+
277
+ try:
278
+ await self.connect()
279
+ # The GatewayClient's _receive_loop will keep running.
280
+ # This run method effectively waits until the client is closed or an unhandled error occurs.
281
+ # A more robust implementation might have a main loop here that monitors gateway health.
282
+ # For now, we rely on the gateway's tasks.
283
+ while not self._closed:
284
+ if (
285
+ self._gateway
286
+ and self._gateway._receive_task
287
+ and self._gateway._receive_task.done()
288
+ ):
289
+ # If receive task ended unexpectedly, try to handle it or re-raise
290
+ try:
291
+ exc = self._gateway._receive_task.exception()
292
+ if exc:
293
+ print(
294
+ f"Gateway receive task ended with exception: {exc}. Attempting to reconnect..."
295
+ )
296
+ # This is a basic reconnect strategy from the client side.
297
+ # GatewayClient itself might handle some reconnects.
298
+ await self.close_gateway(
299
+ code=1000
300
+ ) # Close current gateway state
301
+ await asyncio.sleep(5) # Wait before reconnecting
302
+ if (
303
+ not self._closed
304
+ ): # If client wasn't closed by the exception handler
305
+ await self.connect()
306
+ else:
307
+ break # Client was closed, exit run loop
308
+ else:
309
+ print(
310
+ "Gateway receive task ended without exception. Assuming clean shutdown or reconnect handled internally."
311
+ )
312
+ if (
313
+ not self._closed
314
+ ): # If not explicitly closed, might be an issue
315
+ print(
316
+ "Warning: Gateway receive task ended but client not closed. This might indicate an issue."
317
+ )
318
+ # Consider a more robust health check or reconnect strategy here.
319
+ await asyncio.sleep(
320
+ 1
321
+ ) # Prevent tight loop if something is wrong
322
+ else:
323
+ break # Client was closed
324
+ except asyncio.CancelledError:
325
+ print("Gateway receive task was cancelled.")
326
+ break # Exit if cancelled
327
+ except Exception as e:
328
+ print(f"Error checking gateway receive task: {e}")
329
+ break # Exit on other errors
330
+ await asyncio.sleep(1) # Main loop check interval
331
+ except DisagreementException as e:
332
+ print(f"Client run loop encountered an error: {e}")
333
+ # Error already logged by connect or other methods
334
+ except asyncio.CancelledError:
335
+ print("Client run loop was cancelled.")
336
+ finally:
337
+ if not self._closed:
338
+ await self.close()
339
+
340
+ async def close(self) -> None:
341
+ """
342
+ Closes the connection to Discord. This method is a coroutine.
343
+ """
344
+ if self._closed:
345
+ return
346
+
347
+ self._closed = True
348
+ print("Closing client...")
349
+
350
+ if self._shard_manager:
351
+ await self._shard_manager.close()
352
+ self._shard_manager = None
353
+ if self._gateway:
354
+ await self._gateway.close()
355
+
356
+ if self._http: # HTTPClient has its own session to close
357
+ await self._http.close()
358
+
359
+ self._ready_event.set() # Ensure any waiters for ready are unblocked
360
+ print("Client closed.")
361
+
362
+ async def __aenter__(self) -> "Client":
363
+ """Enter the context manager by connecting to Discord."""
364
+ await self.connect()
365
+ return self
366
+
367
+ async def __aexit__(
368
+ self,
369
+ exc_type: Optional[type],
370
+ exc: Optional[BaseException],
371
+ tb: Optional[BaseException],
372
+ ) -> bool:
373
+ """Exit the context manager and close the client."""
374
+ await self.close()
375
+ return False
376
+
377
+ async def close_gateway(self, code: int = 1000) -> None:
378
+ """Closes only the gateway connection, allowing for potential reconnect."""
379
+ if self._shard_manager:
380
+ await self._shard_manager.close()
381
+ self._shard_manager = None
382
+ if self._gateway:
383
+ await self._gateway.close(code=code)
384
+ self._gateway = None
385
+ self._ready_event.clear() # No longer ready if gateway is closed
386
+
387
+ def is_closed(self) -> bool:
388
+ """Indicates if the client has been closed."""
389
+ return self._closed
390
+
391
+ def is_ready(self) -> bool:
392
+ """Indicates if the client has successfully connected to the Gateway and is ready."""
393
+ return self._ready_event.is_set()
394
+
395
+ @property
396
+ def latency(self) -> Optional[float]:
397
+ """Returns the gateway latency in seconds, or ``None`` if unavailable."""
398
+ if self._gateway:
399
+ return self._gateway.latency
400
+ return None
401
+
402
+ async def wait_until_ready(self) -> None:
403
+ """|coro|
404
+ Waits until the client is fully connected to Discord and the initial state is processed.
405
+ This is mainly useful for waiting for the READY event from the Gateway.
406
+ """
407
+ await self._ready_event.wait()
408
+
409
+ async def wait_for(
410
+ self,
411
+ event_name: str,
412
+ check: Optional[Callable[[Any], bool]] = None,
413
+ timeout: Optional[float] = None,
414
+ ) -> Any:
415
+ """|coro|
416
+ Waits for a specific event to occur that satisfies the ``check``.
417
+
418
+ Parameters
419
+ ----------
420
+ event_name: str
421
+ The name of the event to wait for.
422
+ check: Optional[Callable[[Any], bool]]
423
+ A function that determines whether the received event should resolve the wait.
424
+ timeout: Optional[float]
425
+ How long to wait for the event before raising :class:`asyncio.TimeoutError`.
426
+ """
427
+
428
+ future: asyncio.Future = self.loop.create_future()
429
+ self._event_dispatcher.add_waiter(event_name, future, check)
430
+ try:
431
+ return await asyncio.wait_for(future, timeout=timeout)
432
+ finally:
433
+ self._event_dispatcher.remove_waiter(event_name, future)
434
+
435
+ async def change_presence(
436
+ self,
437
+ status: str,
438
+ activity_name: Optional[str] = None,
439
+ activity_type: int = 0,
440
+ since: int = 0,
441
+ afk: bool = False,
442
+ ):
443
+ """
444
+ Changes the client's presence on Discord.
445
+
446
+ Args:
447
+ status (str): The new status for the client (e.g., "online", "idle", "dnd", "invisible").
448
+ activity_name (Optional[str]): The name of the activity.
449
+ activity_type (int): The type of the activity.
450
+ since (int): The timestamp (in milliseconds) of when the client went idle.
451
+ afk (bool): Whether the client is AFK.
452
+ """
453
+ if self._closed:
454
+ raise DisagreementException("Client is closed.")
455
+
456
+ if self._gateway:
457
+ await self._gateway.update_presence(
458
+ status=status,
459
+ activity_name=activity_name,
460
+ activity_type=activity_type,
461
+ since=since,
462
+ afk=afk,
463
+ )
464
+
465
+ # --- Event Handling ---
466
+
467
+ def event(
468
+ self, coro: Callable[..., Awaitable[None]]
469
+ ) -> Callable[..., Awaitable[None]]:
470
+ """
471
+ A decorator that registers an event to listen to.
472
+ The name of the coroutine is used as the event name.
473
+ Example:
474
+ @client.event
475
+ async def on_ready(): # Will listen for the 'READY' event
476
+ print("Bot is ready!")
477
+
478
+ @client.event
479
+ async def on_message(message: disagreement.Message): # Will listen for 'MESSAGE_CREATE'
480
+ print(f"Message from {message.author}: {message.content}")
481
+ """
482
+ if not asyncio.iscoroutinefunction(coro):
483
+ raise TypeError("Event registered must be a coroutine function.")
484
+
485
+ event_name = coro.__name__
486
+ # Map common function names to Discord event types
487
+ # e.g., on_ready -> READY, on_message -> MESSAGE_CREATE
488
+ if event_name.startswith("on_"):
489
+ discord_event_name = event_name[3:].upper()
490
+ mapping = {
491
+ "MESSAGE": "MESSAGE_CREATE",
492
+ "MESSAGE_EDIT": "MESSAGE_UPDATE",
493
+ "MESSAGE_UPDATE": "MESSAGE_UPDATE",
494
+ "MESSAGE_DELETE": "MESSAGE_DELETE",
495
+ "REACTION_ADD": "MESSAGE_REACTION_ADD",
496
+ "REACTION_REMOVE": "MESSAGE_REACTION_REMOVE",
497
+ }
498
+ discord_event_name = mapping.get(discord_event_name, discord_event_name)
499
+ self._event_dispatcher.register(discord_event_name, coro)
500
+ else:
501
+ # If not starting with "on_", assume it's the direct Discord event name (e.g. "TYPING_START")
502
+ # Or raise an error if a specific format is required.
503
+ # For now, let's assume direct mapping if no "on_" prefix.
504
+ self._event_dispatcher.register(event_name.upper(), coro)
505
+
506
+ return coro # Return the original coroutine
507
+
508
+ def on_event(
509
+ self, event_name: str
510
+ ) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
511
+ """
512
+ A decorator that registers an event to listen to with a specific event name.
513
+ Example:
514
+ @client.on_event('MESSAGE_CREATE')
515
+ async def my_message_handler(message: disagreement.Message):
516
+ print(f"Message: {message.content}")
517
+ """
518
+
519
+ def decorator(
520
+ coro: Callable[..., Awaitable[None]],
521
+ ) -> Callable[..., Awaitable[None]]:
522
+ if not asyncio.iscoroutinefunction(coro):
523
+ raise TypeError("Event registered must be a coroutine function.")
524
+ self._event_dispatcher.register(event_name.upper(), coro)
525
+ return coro
526
+
527
+ return decorator
528
+
529
+ async def _process_message_for_commands(self, message: "Message") -> None:
530
+ """Internal listener to process messages for commands."""
531
+ # Make sure message object is valid and not from a bot (optional, common check)
532
+ if (
533
+ not message or not message.author or message.author.bot
534
+ ): # Add .bot check to User model
535
+ return
536
+ await self.command_handler.process_commands(message)
537
+
538
+ # --- Command Framework Methods ---
539
+
540
+ def add_cog(self, cog: Cog) -> None:
541
+ """
542
+ Adds a Cog to the bot.
543
+ Cogs are classes that group commands, listeners, and state.
544
+ This will also discover and register any application commands defined in the cog.
545
+
546
+ Args:
547
+ cog (Cog): An instance of a class derived from `disagreement.ext.commands.Cog`.
548
+ """
549
+ # Add to prefix command handler
550
+ self.command_handler.add_cog(
551
+ cog
552
+ ) # This should call cog._inject() internally or cog._inject() is called on Cog init
553
+
554
+ # Discover and add application commands from the cog
555
+ # AppCommand and AppCommandGroup are already imported in TYPE_CHECKING block
556
+ for app_cmd_obj in cog.get_app_commands_and_groups(): # Uses the new method
557
+ # The cog attribute should have been set within Cog._inject() for AppCommands
558
+ self.app_command_handler.add_command(app_cmd_obj)
559
+ print(
560
+ f"Registered app command/group '{app_cmd_obj.name}' from cog '{cog.cog_name}'."
561
+ )
562
+
563
+ def remove_cog(self, cog_name: str) -> Optional[Cog]:
564
+ """
565
+ Removes a Cog from the bot.
566
+
567
+ Args:
568
+ cog_name (str): The name of the Cog to remove.
569
+
570
+ Returns:
571
+ Optional[Cog]: The Cog that was removed, or None if not found.
572
+ """
573
+ removed_cog = self.command_handler.remove_cog(cog_name)
574
+ if removed_cog:
575
+ # Also remove associated application commands
576
+ # This requires AppCommand to store a reference to its cog, or iterate all app_commands.
577
+ # Assuming AppCommand has a .cog attribute, which is set in Cog._inject()
578
+ # And AppCommandGroup might store commands that have .cog attribute
579
+ for app_cmd_or_group in removed_cog.get_app_commands_and_groups():
580
+ # The AppCommandHandler.remove_command needs to handle both AppCommand and AppCommandGroup
581
+ self.app_command_handler.remove_command(
582
+ app_cmd_or_group.name
583
+ ) # Assuming name is unique enough for removal here
584
+ print(
585
+ f"Removed app command/group '{app_cmd_or_group.name}' from cog '{cog_name}'."
586
+ )
587
+ # Note: AppCommandHandler.remove_command might need to be more specific if names aren't globally unique
588
+ # (e.g. if it needs type or if groups and commands can share names).
589
+ # For now, assuming name is sufficient for removal from the handler's flat list.
590
+ return removed_cog
591
+
592
+ def check(self, coro: Callable[["CommandContext"], Awaitable[bool]]):
593
+ """
594
+ A decorator that adds a global check to the bot.
595
+ This check will be called for every command before it's executed.
596
+
597
+ Example:
598
+ @bot.check
599
+ async def block_dms(ctx):
600
+ return ctx.guild is not None
601
+ """
602
+ self.command_handler.add_check(coro)
603
+ return coro
604
+
605
+ def command(
606
+ self, **attrs: Any
607
+ ) -> Callable[[Callable[..., Awaitable[None]]], Command]:
608
+ """A decorator that transforms a function into a Command."""
609
+
610
+ def decorator(func: Callable[..., Awaitable[None]]) -> Command:
611
+ cmd = Command(func, **attrs)
612
+ self.command_handler.add_command(cmd)
613
+ return cmd
614
+
615
+ return decorator
616
+
617
+ def group(self, **attrs: Any) -> Callable[[Callable[..., Awaitable[None]]], Group]:
618
+ """A decorator that transforms a function into a Group command."""
619
+
620
+ def decorator(func: Callable[..., Awaitable[None]]) -> Group:
621
+ cmd = Group(func, **attrs)
622
+ self.command_handler.add_command(cmd)
623
+ return cmd
624
+
625
+ return decorator
626
+
627
+ def add_app_command(self, command: Union["AppCommand", "AppCommandGroup"]) -> None:
628
+ """
629
+ Adds a standalone application command or group to the bot.
630
+ Use this for commands not defined within a Cog.
631
+
632
+ Args:
633
+ command (Union[AppCommand, AppCommandGroup]): The application command or group instance.
634
+ This is typically the object returned by a decorator like @slash_command.
635
+ """
636
+ from .ext.app_commands.commands import (
637
+ AppCommand,
638
+ AppCommandGroup,
639
+ ) # Ensure types
640
+
641
+ if not isinstance(command, (AppCommand, AppCommandGroup)):
642
+ raise TypeError(
643
+ "Command must be an instance of AppCommand or AppCommandGroup."
644
+ )
645
+
646
+ # If it's a decorated function, the command object might be on __app_command_object__
647
+ if hasattr(command, "__app_command_object__") and isinstance(
648
+ getattr(command, "__app_command_object__"), (AppCommand, AppCommandGroup)
649
+ ):
650
+ actual_command_obj = getattr(command, "__app_command_object__")
651
+ self.app_command_handler.add_command(actual_command_obj)
652
+ print(
653
+ f"Registered standalone app command/group '{actual_command_obj.name}'."
654
+ )
655
+ elif isinstance(
656
+ command, (AppCommand, AppCommandGroup)
657
+ ): # It's already the command object
658
+ self.app_command_handler.add_command(command)
659
+ print(f"Registered standalone app command/group '{command.name}'.")
660
+ else:
661
+ # This case should ideally not be hit if type checks are done by decorators
662
+ print(
663
+ f"Warning: Could not register app command {command}. It's not a recognized command object or decorated function."
664
+ )
665
+
666
+ async def on_command_error(
667
+ self, ctx: "CommandContext", error: "CommandError"
668
+ ) -> None:
669
+ """
670
+ Default command error handler. Called when a command raises an error.
671
+ Users can override this method in a subclass of Client to implement custom error handling.
672
+
673
+ Args:
674
+ ctx (CommandContext): The context of the command that raised the error.
675
+ error (CommandError): The error that was raised.
676
+ """
677
+ # Default behavior: print to console.
678
+ # Users might want to send a message to ctx.channel or log to a file.
679
+ print(
680
+ f"Error in command '{ctx.command.name if ctx.command else 'unknown'}': {error}"
681
+ )
682
+
683
+ # Need to import CommandInvokeError for this check if not already globally available
684
+ # For now, assuming it's imported via TYPE_CHECKING or directly if needed at runtime
685
+ from .ext.commands.errors import (
686
+ CommandInvokeError as CIE,
687
+ ) # Local import for isinstance check
688
+
689
+ if isinstance(error, CIE):
690
+ # Now it's safe to access error.original
691
+ print(
692
+ f"Original exception: {type(error.original).__name__}: {error.original}"
693
+ )
694
+ # import traceback
695
+ # traceback.print_exception(type(error.original), error.original, error.original.__traceback__)
696
+
697
+ async def on_command_completion(self, ctx: "CommandContext") -> None:
698
+ """
699
+ Default command completion handler. Called when a command has successfully completed.
700
+ Users can override this method in a subclass of Client.
701
+
702
+ Args:
703
+ ctx (CommandContext): The context of the command that completed.
704
+ """
705
+ pass
706
+
707
+ # --- Extension Management Methods ---
708
+
709
+ def load_extension(self, name: str) -> ModuleType:
710
+ """Load an extension by name using :mod:`disagreement.ext.loader`."""
711
+
712
+ return ext_loader.load_extension(name)
713
+
714
+ def unload_extension(self, name: str) -> None:
715
+ """Unload a previously loaded extension."""
716
+
717
+ ext_loader.unload_extension(name)
718
+
719
+ def reload_extension(self, name: str) -> ModuleType:
720
+ """Reload an extension by name."""
721
+
722
+ return ext_loader.reload_extension(name)
723
+
724
+ # --- Model Parsing and Fetching ---
725
+
726
+ def parse_user(self, data: Dict[str, Any]) -> "User":
727
+ """Parses user data and returns a User object, updating cache."""
728
+ from .models import User # Ensure User model is available
729
+
730
+ user = User(data)
731
+ self._users.set(user.id, user) # Cache the user
732
+ return user
733
+
734
+ def parse_channel(self, data: Dict[str, Any]) -> "Channel":
735
+ """Parses channel data and returns a Channel object, updating caches."""
736
+
737
+ from .models import channel_factory
738
+
739
+ channel = channel_factory(data, self)
740
+ self._channels.set(channel.id, channel)
741
+ if channel.guild_id:
742
+ guild = self._guilds.get(channel.guild_id)
743
+ if guild:
744
+ guild._channels.set(channel.id, channel)
745
+ return channel
746
+
747
+ def parse_message(self, data: Dict[str, Any]) -> "Message":
748
+ """Parses message data and returns a Message object, updating cache."""
749
+
750
+ from .models import Message
751
+
752
+ message = Message(data, client_instance=self)
753
+ self._messages.set(message.id, message)
754
+ return message
755
+
756
+ def parse_webhook(self, data: Union[Dict[str, Any], "Webhook"]) -> "Webhook":
757
+ """Parses webhook data and returns a Webhook object, updating cache."""
758
+
759
+ from .models import Webhook
760
+
761
+ if isinstance(data, Webhook):
762
+ webhook = data
763
+ webhook._client = self # type: ignore[attr-defined]
764
+ else:
765
+ webhook = Webhook(data, client_instance=self)
766
+ self._webhooks[webhook.id] = webhook
767
+ return webhook
768
+
769
+ def parse_template(self, data: Dict[str, Any]) -> "GuildTemplate":
770
+ """Parses template data into a GuildTemplate object."""
771
+
772
+ from .models import GuildTemplate
773
+
774
+ return GuildTemplate(data, client_instance=self)
775
+
776
+ def parse_scheduled_event(self, data: Dict[str, Any]) -> "ScheduledEvent":
777
+ """Parses scheduled event data and updates cache."""
778
+
779
+ from .models import ScheduledEvent
780
+
781
+ event = ScheduledEvent(data, client_instance=self)
782
+ # Cache by ID under guild if guild cache exists
783
+ guild = self._guilds.get(event.guild_id)
784
+ if guild is not None:
785
+ events = getattr(guild, "_scheduled_events", {})
786
+ events[event.id] = event
787
+ setattr(guild, "_scheduled_events", events)
788
+ return event
789
+
790
+ def parse_audit_log_entry(self, data: Dict[str, Any]) -> "AuditLogEntry":
791
+ """Parses audit log entry data."""
792
+ from .models import AuditLogEntry
793
+
794
+ return AuditLogEntry(data, client_instance=self)
795
+
796
+ def parse_invite(self, data: Dict[str, Any]) -> "Invite":
797
+ """Parses invite data into an :class:`Invite`."""
798
+
799
+ from .models import Invite
800
+
801
+ return Invite.from_dict(data)
802
+
803
+ async def fetch_user(self, user_id: Snowflake) -> Optional["User"]:
804
+ """Fetches a user by ID from Discord."""
805
+ if self._closed:
806
+ raise DisagreementException("Client is closed.")
807
+
808
+ cached_user = self._users.get(user_id)
809
+ if cached_user:
810
+ return cached_user
811
+
812
+ try:
813
+ user_data = await self._http.get_user(user_id)
814
+ return self.parse_user(user_data)
815
+ except DisagreementException as e: # Catch HTTP exceptions from http client
816
+ print(f"Failed to fetch user {user_id}: {e}")
817
+ return None
818
+
819
+ async def fetch_message(
820
+ self, channel_id: Snowflake, message_id: Snowflake
821
+ ) -> Optional["Message"]:
822
+ """Fetches a message by ID from Discord and caches it."""
823
+
824
+ if self._closed:
825
+ raise DisagreementException("Client is closed.")
826
+
827
+ cached_message = self._messages.get(message_id)
828
+ if cached_message:
829
+ return cached_message
830
+
831
+ try:
832
+ message_data = await self._http.get_message(channel_id, message_id)
833
+ return self.parse_message(message_data)
834
+ except DisagreementException as e:
835
+ print(
836
+ f"Failed to fetch message {message_id} from channel {channel_id}: {e}"
837
+ )
838
+ return None
839
+
840
+ def parse_member(
841
+ self, data: Dict[str, Any], guild_id: Snowflake, *, just_joined: bool = False
842
+ ) -> "Member":
843
+ """Parses member data and returns a Member object, updating relevant caches."""
844
+ from .models import Member
845
+
846
+ member = Member(data, client_instance=self)
847
+ member.guild_id = str(guild_id)
848
+
849
+ if just_joined:
850
+ setattr(member, "_just_joined", True)
851
+
852
+ guild = self._guilds.get(guild_id)
853
+ if guild:
854
+ guild._members.set(member.id, member)
855
+
856
+ if just_joined and hasattr(member, "_just_joined"):
857
+ delattr(member, "_just_joined")
858
+
859
+ self._users.set(member.id, member)
860
+ return member
861
+
862
+ async def fetch_member(
863
+ self, guild_id: Snowflake, member_id: Snowflake
864
+ ) -> Optional["Member"]:
865
+ """Fetches a member from a guild by ID."""
866
+ if self._closed:
867
+ raise DisagreementException("Client is closed.")
868
+
869
+ guild = self.get_guild(guild_id)
870
+ if guild:
871
+ cached_member = guild.get_member(member_id) # Use Guild's get_member
872
+ if cached_member:
873
+ return cached_member # Return cached if available
874
+
875
+ try:
876
+ member_data = await self._http.get_guild_member(guild_id, member_id)
877
+ return self.parse_member(member_data, guild_id)
878
+ except DisagreementException as e:
879
+ print(f"Failed to fetch member {member_id} from guild {guild_id}: {e}")
880
+ return None
881
+
882
+ def parse_role(self, data: Dict[str, Any], guild_id: Snowflake) -> "Role":
883
+ """Parses role data and returns a Role object, updating guild's role cache."""
884
+ from .models import Role # Ensure Role model is available
885
+
886
+ role = Role(data)
887
+ guild = self._guilds.get(guild_id)
888
+ if guild:
889
+ # Update the role in the guild's roles list if it exists, or add it.
890
+ # Guild.roles is List[Role]. We need to find and replace or append.
891
+ found = False
892
+ for i, existing_role in enumerate(guild.roles):
893
+ if existing_role.id == role.id:
894
+ guild.roles[i] = role
895
+ found = True
896
+ break
897
+ if not found:
898
+ guild.roles.append(role)
899
+ return role
900
+
901
+ def parse_guild(self, data: Dict[str, Any]) -> "Guild":
902
+ """Parses guild data and returns a Guild object, updating cache."""
903
+ from .models import Guild
904
+
905
+ guild = Guild(data, client_instance=self)
906
+ self._guilds.set(guild.id, guild)
907
+
908
+ presences = {p["user"]["id"]: p for p in data.get("presences", [])}
909
+ voice_states = {vs["user_id"]: vs for vs in data.get("voice_states", [])}
910
+
911
+ for ch_data in data.get("channels", []):
912
+ self.parse_channel(ch_data)
913
+
914
+ for member_data in data.get("members", []):
915
+ user_id = member_data.get("user", {}).get("id")
916
+ if user_id:
917
+ presence = presences.get(user_id)
918
+ if presence:
919
+ member_data["status"] = presence.get("status", "offline")
920
+
921
+ voice_state = voice_states.get(user_id)
922
+ if voice_state:
923
+ member_data["voice_state"] = voice_state
924
+
925
+ self.parse_member(member_data, guild.id)
926
+
927
+ return guild
928
+
929
+ async def fetch_roles(self, guild_id: Snowflake) -> List["Role"]:
930
+ """Fetches all roles for a given guild and caches them.
931
+
932
+ If the guild is not cached, it will be retrieved first using
933
+ :meth:`fetch_guild`.
934
+ """
935
+ if self._closed:
936
+ raise DisagreementException("Client is closed.")
937
+ guild = self.get_guild(guild_id)
938
+ if not guild:
939
+ guild = await self.fetch_guild(guild_id)
940
+ if not guild:
941
+ return []
942
+
943
+ try:
944
+ roles_data = await self._http.get_guild_roles(guild_id)
945
+ parsed_roles = []
946
+ for role_data in roles_data:
947
+ # parse_role will add/update it in the guild.roles list
948
+ parsed_roles.append(self.parse_role(role_data, guild_id))
949
+ guild.roles = parsed_roles # Replace the entire list with the fresh one
950
+ return parsed_roles
951
+ except DisagreementException as e:
952
+ print(f"Failed to fetch roles for guild {guild_id}: {e}")
953
+ return []
954
+
955
+ async def fetch_role(
956
+ self, guild_id: Snowflake, role_id: Snowflake
957
+ ) -> Optional["Role"]:
958
+ """Fetches a specific role from a guild by ID.
959
+ If roles for the guild aren't cached or might be stale, it fetches all roles first.
960
+ """
961
+ guild = self.get_guild(guild_id)
962
+ if guild:
963
+ # Try to find in existing guild.roles
964
+ for role in guild.roles:
965
+ if role.id == role_id:
966
+ return role
967
+
968
+ # If not found in cache or guild doesn't exist yet in cache, fetch all roles for the guild
969
+ await self.fetch_roles(guild_id) # This will populate/update guild.roles
970
+
971
+ # Try again from the now (hopefully) populated cache
972
+ guild = self.get_guild(
973
+ guild_id
974
+ ) # Re-get guild in case it was populated by fetch_roles
975
+ if guild:
976
+ for role in guild.roles:
977
+ if role.id == role_id:
978
+ return role
979
+
980
+ return None # Role not found even after fetching
981
+
982
+ # --- API Methods ---
983
+
984
+ # --- API Methods ---
985
+
986
+ async def send_message(
987
+ self,
988
+ channel_id: str,
989
+ content: Optional[str] = None,
990
+ *, # Make additional params keyword-only
991
+ tts: bool = False,
992
+ embed: Optional["Embed"] = None,
993
+ embeds: Optional[List["Embed"]] = None,
994
+ components: Optional[List["ActionRow"]] = None,
995
+ allowed_mentions: Optional[Dict[str, Any]] = None,
996
+ message_reference: Optional[Dict[str, Any]] = None,
997
+ attachments: Optional[List[Any]] = None,
998
+ files: Optional[List[Any]] = None,
999
+ flags: Optional[int] = None,
1000
+ view: Optional["View"] = None,
1001
+ ) -> "Message":
1002
+ """|coro|
1003
+ Sends a message to the specified channel.
1004
+
1005
+ Args:
1006
+ channel_id (str): The ID of the channel to send the message to.
1007
+ content (Optional[str]): The content of the message.
1008
+ tts (bool): Whether the message should be sent with text-to-speech. Defaults to False.
1009
+ embed (Optional[Embed]): A single embed to send. Cannot be used with `embeds`.
1010
+ embeds (Optional[List[Embed]]): A list of embeds to send. Cannot be used with `embed`.
1011
+ Discord supports up to 10 embeds per message.
1012
+ components (Optional[List[ActionRow]]): A list of ActionRow components to include.
1013
+ allowed_mentions (Optional[Dict[str, Any]]): Allowed mentions for the message.
1014
+ message_reference (Optional[Dict[str, Any]]): Message reference for replying.
1015
+ attachments (Optional[List[Any]]): Attachments to include with the message.
1016
+ files (Optional[List[Any]]): Files to upload with the message.
1017
+ flags (Optional[int]): Message flags.
1018
+ view (Optional[View]): A view to send with the message.
1019
+
1020
+ Returns:
1021
+ Message: The message that was sent.
1022
+
1023
+ Raises:
1024
+ HTTPException: Sending the message failed.
1025
+ ValueError: If both `embed` and `embeds` are provided, or if both `components` and `view` are provided.
1026
+ """
1027
+ if self._closed:
1028
+ raise DisagreementException("Client is closed.")
1029
+
1030
+ if embed and embeds:
1031
+ raise ValueError("Cannot provide both embed and embeds.")
1032
+ if components and view:
1033
+ raise ValueError("Cannot provide both 'components' and 'view'.")
1034
+
1035
+ final_embeds_payload: Optional[List[Dict[str, Any]]] = None
1036
+ if embed:
1037
+ final_embeds_payload = [embed.to_dict()]
1038
+ elif embeds:
1039
+ from .models import (
1040
+ Embed as EmbedModel,
1041
+ )
1042
+
1043
+ final_embeds_payload = [
1044
+ e.to_dict() for e in embeds if isinstance(e, EmbedModel)
1045
+ ]
1046
+
1047
+ components_payload: Optional[List[Dict[str, Any]]] = None
1048
+ if view:
1049
+ await view._start(self)
1050
+ components_payload = view.to_components_payload()
1051
+ elif components:
1052
+ from .models import Component as ComponentModel
1053
+
1054
+ components_payload = [
1055
+ comp.to_dict()
1056
+ for comp in components
1057
+ if isinstance(comp, ComponentModel)
1058
+ ]
1059
+
1060
+ message_data = await self._http.send_message(
1061
+ channel_id=channel_id,
1062
+ content=content,
1063
+ tts=tts,
1064
+ embeds=final_embeds_payload,
1065
+ components=components_payload,
1066
+ allowed_mentions=allowed_mentions,
1067
+ message_reference=message_reference,
1068
+ attachments=attachments,
1069
+ files=files,
1070
+ flags=flags,
1071
+ )
1072
+
1073
+ if view:
1074
+ message_id = message_data["id"]
1075
+ view.message_id = message_id
1076
+ self._views[message_id] = view
1077
+
1078
+ return self.parse_message(message_data)
1079
+
1080
+ def typing(self, channel_id: str) -> Typing:
1081
+ """Return a context manager to show a typing indicator in a channel."""
1082
+
1083
+ return Typing(self, channel_id)
1084
+
1085
+ async def join_voice(
1086
+ self,
1087
+ guild_id: Snowflake,
1088
+ channel_id: Snowflake,
1089
+ *,
1090
+ self_mute: bool = False,
1091
+ self_deaf: bool = False,
1092
+ ) -> VoiceClient:
1093
+ """|coro| Join a voice channel and return a :class:`VoiceClient`."""
1094
+
1095
+ if self._closed:
1096
+ raise DisagreementException("Client is closed.")
1097
+ if not self.is_ready():
1098
+ await self.wait_until_ready()
1099
+ if self._gateway is None:
1100
+ raise DisagreementException("Gateway is not connected.")
1101
+ if not self.user:
1102
+ raise DisagreementException("Client user unavailable.")
1103
+ assert self.user is not None
1104
+ user_id = self.user.id
1105
+
1106
+ if guild_id in self._voice_clients:
1107
+ return self._voice_clients[guild_id]
1108
+
1109
+ payload = {
1110
+ "op": GatewayOpcode.VOICE_STATE_UPDATE,
1111
+ "d": {
1112
+ "guild_id": str(guild_id),
1113
+ "channel_id": str(channel_id),
1114
+ "self_mute": self_mute,
1115
+ "self_deaf": self_deaf,
1116
+ },
1117
+ }
1118
+ await self._gateway._send_json(payload) # type: ignore[attr-defined]
1119
+
1120
+ server = await self.wait_for(
1121
+ "VOICE_SERVER_UPDATE",
1122
+ check=lambda d: d.get("guild_id") == str(guild_id),
1123
+ timeout=10,
1124
+ )
1125
+ state = await self.wait_for(
1126
+ "VOICE_STATE_UPDATE",
1127
+ check=lambda d, uid=user_id: d.get("guild_id") == str(guild_id)
1128
+ and d.get("user_id") == str(uid),
1129
+ timeout=10,
1130
+ )
1131
+
1132
+ endpoint = f"wss://{server['endpoint']}?v=10"
1133
+ token = server["token"]
1134
+ session_id = state["session_id"]
1135
+
1136
+ voice = VoiceClient(
1137
+ self,
1138
+ endpoint,
1139
+ session_id,
1140
+ token,
1141
+ int(guild_id),
1142
+ int(self.user.id),
1143
+ verbose=self.verbose,
1144
+ )
1145
+ await voice.connect()
1146
+ self._voice_clients[guild_id] = voice
1147
+ return voice
1148
+
1149
+ async def add_reaction(self, channel_id: str, message_id: str, emoji: str) -> None:
1150
+ """|coro| Add a reaction to a message."""
1151
+
1152
+ await self.create_reaction(channel_id, message_id, emoji)
1153
+
1154
+ async def remove_reaction(
1155
+ self, channel_id: str, message_id: str, emoji: str
1156
+ ) -> None:
1157
+ """|coro| Remove the bot's reaction from a message."""
1158
+
1159
+ await self.delete_reaction(channel_id, message_id, emoji)
1160
+
1161
+ async def clear_reactions(self, channel_id: str, message_id: str) -> None:
1162
+ """|coro| Remove all reactions from a message."""
1163
+
1164
+ if self._closed:
1165
+ raise DisagreementException("Client is closed.")
1166
+
1167
+ await self._http.clear_reactions(channel_id, message_id)
1168
+
1169
+ async def create_reaction(
1170
+ self, channel_id: str, message_id: str, emoji: str
1171
+ ) -> None:
1172
+ """|coro| Add a reaction to a message."""
1173
+
1174
+ if self._closed:
1175
+ raise DisagreementException("Client is closed.")
1176
+
1177
+ await self._http.create_reaction(channel_id, message_id, emoji)
1178
+
1179
+ user_id = getattr(getattr(self, "user", None), "id", None)
1180
+ payload = {
1181
+ "user_id": user_id,
1182
+ "channel_id": channel_id,
1183
+ "message_id": message_id,
1184
+ "emoji": {"name": emoji, "id": None},
1185
+ }
1186
+ if hasattr(self, "_event_dispatcher"):
1187
+ await self._event_dispatcher.dispatch("MESSAGE_REACTION_ADD", payload)
1188
+
1189
+ async def delete_reaction(
1190
+ self, channel_id: str, message_id: str, emoji: str
1191
+ ) -> None:
1192
+ """|coro| Remove the bot's reaction from a message."""
1193
+
1194
+ if self._closed:
1195
+ raise DisagreementException("Client is closed.")
1196
+
1197
+ await self._http.delete_reaction(channel_id, message_id, emoji)
1198
+
1199
+ user_id = getattr(getattr(self, "user", None), "id", None)
1200
+ payload = {
1201
+ "user_id": user_id,
1202
+ "channel_id": channel_id,
1203
+ "message_id": message_id,
1204
+ "emoji": {"name": emoji, "id": None},
1205
+ }
1206
+ if hasattr(self, "_event_dispatcher"):
1207
+ await self._event_dispatcher.dispatch("MESSAGE_REACTION_REMOVE", payload)
1208
+
1209
+ async def get_reactions(
1210
+ self, channel_id: str, message_id: str, emoji: str
1211
+ ) -> List["User"]:
1212
+ """|coro| Return the users who reacted with the given emoji."""
1213
+
1214
+ if self._closed:
1215
+ raise DisagreementException("Client is closed.")
1216
+
1217
+ users_data = await self._http.get_reactions(channel_id, message_id, emoji)
1218
+ return [self.parse_user(u) for u in users_data]
1219
+
1220
+ async def edit_message(
1221
+ self,
1222
+ channel_id: str,
1223
+ message_id: str,
1224
+ *,
1225
+ content: Optional[str] = None,
1226
+ embed: Optional["Embed"] = None,
1227
+ embeds: Optional[List["Embed"]] = None,
1228
+ components: Optional[List["ActionRow"]] = None,
1229
+ allowed_mentions: Optional[Dict[str, Any]] = None,
1230
+ flags: Optional[int] = None,
1231
+ view: Optional["View"] = None,
1232
+ ) -> "Message":
1233
+ """Edits a previously sent message."""
1234
+
1235
+ if self._closed:
1236
+ raise DisagreementException("Client is closed.")
1237
+
1238
+ if embed and embeds:
1239
+ raise ValueError("Cannot provide both embed and embeds.")
1240
+ if components and view:
1241
+ raise ValueError("Cannot provide both 'components' and 'view'.")
1242
+
1243
+ final_embeds_payload: Optional[List[Dict[str, Any]]] = None
1244
+ if embed:
1245
+ final_embeds_payload = [embed.to_dict()]
1246
+ elif embeds:
1247
+ final_embeds_payload = [e.to_dict() for e in embeds]
1248
+
1249
+ components_payload: Optional[List[Dict[str, Any]]] = None
1250
+ if view:
1251
+ await view._start(self)
1252
+ components_payload = view.to_components_payload()
1253
+ elif components:
1254
+ components_payload = [c.to_dict() for c in components]
1255
+
1256
+ payload: Dict[str, Any] = {}
1257
+ if content is not None:
1258
+ payload["content"] = content
1259
+ if final_embeds_payload is not None:
1260
+ payload["embeds"] = final_embeds_payload
1261
+ if components_payload is not None:
1262
+ payload["components"] = components_payload
1263
+ if allowed_mentions is not None:
1264
+ payload["allowed_mentions"] = allowed_mentions
1265
+ if flags is not None:
1266
+ payload["flags"] = flags
1267
+
1268
+ message_data = await self._http.edit_message(
1269
+ channel_id=channel_id,
1270
+ message_id=message_id,
1271
+ payload=payload,
1272
+ )
1273
+
1274
+ if view:
1275
+ view.message_id = message_data["id"]
1276
+ self._views[message_data["id"]] = view
1277
+
1278
+ return self.parse_message(message_data)
1279
+
1280
+ def get_guild(self, guild_id: Snowflake) -> Optional["Guild"]:
1281
+ """Returns a guild from the internal cache.
1282
+
1283
+ Use :meth:`fetch_guild` to retrieve it from Discord if it's not cached.
1284
+ """
1285
+
1286
+ return self._guilds.get(guild_id)
1287
+
1288
+ def get_channel(self, channel_id: Snowflake) -> Optional["Channel"]:
1289
+ """Returns a channel from the internal cache."""
1290
+
1291
+ return self._channels.get(channel_id)
1292
+
1293
+ def get_message(self, message_id: Snowflake) -> Optional["Message"]:
1294
+ """Returns a message from the internal cache."""
1295
+
1296
+ return self._messages.get(message_id)
1297
+
1298
+ async def fetch_guild(self, guild_id: Snowflake) -> Optional["Guild"]:
1299
+ """Fetches a guild by ID from Discord and caches it."""
1300
+
1301
+ if self._closed:
1302
+ raise DisagreementException("Client is closed.")
1303
+
1304
+ cached_guild = self._guilds.get(guild_id)
1305
+ if cached_guild:
1306
+ return cached_guild
1307
+
1308
+ try:
1309
+ guild_data = await self._http.get_guild(guild_id)
1310
+ return self.parse_guild(guild_data)
1311
+ except DisagreementException as e:
1312
+ print(f"Failed to fetch guild {guild_id}: {e}")
1313
+ return None
1314
+
1315
+ async def fetch_channel(self, channel_id: Snowflake) -> Optional["Channel"]:
1316
+ """Fetches a channel from Discord by its ID and updates the cache."""
1317
+
1318
+ if self._closed:
1319
+ raise DisagreementException("Client is closed.")
1320
+
1321
+ try:
1322
+ channel_data = await self._http.get_channel(channel_id)
1323
+ if not channel_data:
1324
+ return None
1325
+
1326
+ from .models import channel_factory
1327
+
1328
+ channel = channel_factory(channel_data, self)
1329
+
1330
+ self._channels.set(channel.id, channel)
1331
+ return channel
1332
+
1333
+ except DisagreementException as e: # Includes HTTPException
1334
+ print(f"Failed to fetch channel {channel_id}: {e}")
1335
+ return None
1336
+
1337
+ async def fetch_audit_logs(
1338
+ self, guild_id: Snowflake, **filters: Any
1339
+ ) -> AsyncIterator["AuditLogEntry"]:
1340
+ """Fetch audit log entries for a guild."""
1341
+ if self._closed:
1342
+ raise DisagreementException("Client is closed.")
1343
+
1344
+ data = await self._http.get_audit_logs(guild_id, **filters)
1345
+ for entry in data.get("audit_log_entries", []):
1346
+ yield self.parse_audit_log_entry(entry)
1347
+
1348
+ async def fetch_voice_regions(self) -> List[VoiceRegion]:
1349
+ """Fetches available voice regions."""
1350
+
1351
+ if self._closed:
1352
+ raise DisagreementException("Client is closed.")
1353
+
1354
+ data = await self._http.get_voice_regions()
1355
+ regions = []
1356
+ for region in data:
1357
+ region_id = region.get("id")
1358
+ if region_id:
1359
+ regions.append(VoiceRegion(region_id))
1360
+ return regions
1361
+
1362
+ async def create_webhook(
1363
+ self, channel_id: Snowflake, payload: Dict[str, Any]
1364
+ ) -> "Webhook":
1365
+ """|coro| Create a webhook in the given channel."""
1366
+
1367
+ if self._closed:
1368
+ raise DisagreementException("Client is closed.")
1369
+
1370
+ data = await self._http.create_webhook(channel_id, payload)
1371
+ return self.parse_webhook(data)
1372
+
1373
+ async def edit_webhook(
1374
+ self, webhook_id: Snowflake, payload: Dict[str, Any]
1375
+ ) -> "Webhook":
1376
+ """|coro| Edit an existing webhook."""
1377
+
1378
+ if self._closed:
1379
+ raise DisagreementException("Client is closed.")
1380
+
1381
+ data = await self._http.edit_webhook(webhook_id, payload)
1382
+ return self.parse_webhook(data)
1383
+
1384
+ async def delete_webhook(self, webhook_id: Snowflake) -> None:
1385
+ """|coro| Delete a webhook by ID."""
1386
+
1387
+ if self._closed:
1388
+ raise DisagreementException("Client is closed.")
1389
+
1390
+ await self._http.delete_webhook(webhook_id)
1391
+
1392
+ async def fetch_templates(self, guild_id: Snowflake) -> List["GuildTemplate"]:
1393
+ """|coro| Fetch all templates for a guild."""
1394
+
1395
+ if self._closed:
1396
+ raise DisagreementException("Client is closed.")
1397
+
1398
+ data = await self._http.get_guild_templates(guild_id)
1399
+ return [self.parse_template(t) for t in data]
1400
+
1401
+ async def create_template(
1402
+ self, guild_id: Snowflake, payload: Dict[str, Any]
1403
+ ) -> "GuildTemplate":
1404
+ """|coro| Create a template for a guild."""
1405
+
1406
+ if self._closed:
1407
+ raise DisagreementException("Client is closed.")
1408
+
1409
+ data = await self._http.create_guild_template(guild_id, payload)
1410
+ return self.parse_template(data)
1411
+
1412
+ async def sync_template(
1413
+ self, guild_id: Snowflake, template_code: str
1414
+ ) -> "GuildTemplate":
1415
+ """|coro| Sync a template to the guild's current state."""
1416
+
1417
+ if self._closed:
1418
+ raise DisagreementException("Client is closed.")
1419
+
1420
+ data = await self._http.sync_guild_template(guild_id, template_code)
1421
+ return self.parse_template(data)
1422
+
1423
+ async def delete_template(self, guild_id: Snowflake, template_code: str) -> None:
1424
+ """|coro| Delete a guild template."""
1425
+
1426
+ if self._closed:
1427
+ raise DisagreementException("Client is closed.")
1428
+
1429
+ await self._http.delete_guild_template(guild_id, template_code)
1430
+
1431
+ async def fetch_scheduled_events(
1432
+ self, guild_id: Snowflake
1433
+ ) -> List["ScheduledEvent"]:
1434
+ """|coro| Fetch all scheduled events for a guild."""
1435
+
1436
+ if self._closed:
1437
+ raise DisagreementException("Client is closed.")
1438
+
1439
+ data = await self._http.get_guild_scheduled_events(guild_id)
1440
+ return [self.parse_scheduled_event(ev) for ev in data]
1441
+
1442
+ async def fetch_scheduled_event(
1443
+ self, guild_id: Snowflake, event_id: Snowflake
1444
+ ) -> Optional["ScheduledEvent"]:
1445
+ """|coro| Fetch a single scheduled event."""
1446
+
1447
+ if self._closed:
1448
+ raise DisagreementException("Client is closed.")
1449
+
1450
+ try:
1451
+ data = await self._http.get_guild_scheduled_event(guild_id, event_id)
1452
+ return self.parse_scheduled_event(data)
1453
+ except DisagreementException as e:
1454
+ print(f"Failed to fetch scheduled event {event_id}: {e}")
1455
+ return None
1456
+
1457
+ async def create_scheduled_event(
1458
+ self, guild_id: Snowflake, payload: Dict[str, Any]
1459
+ ) -> "ScheduledEvent":
1460
+ """|coro| Create a scheduled event in a guild."""
1461
+
1462
+ if self._closed:
1463
+ raise DisagreementException("Client is closed.")
1464
+
1465
+ data = await self._http.create_guild_scheduled_event(guild_id, payload)
1466
+ return self.parse_scheduled_event(data)
1467
+
1468
+ async def edit_scheduled_event(
1469
+ self, guild_id: Snowflake, event_id: Snowflake, payload: Dict[str, Any]
1470
+ ) -> "ScheduledEvent":
1471
+ """|coro| Edit an existing scheduled event."""
1472
+
1473
+ if self._closed:
1474
+ raise DisagreementException("Client is closed.")
1475
+
1476
+ data = await self._http.edit_guild_scheduled_event(guild_id, event_id, payload)
1477
+ return self.parse_scheduled_event(data)
1478
+
1479
+ async def delete_scheduled_event(
1480
+ self, guild_id: Snowflake, event_id: Snowflake
1481
+ ) -> None:
1482
+ """|coro| Delete a scheduled event."""
1483
+
1484
+ if self._closed:
1485
+ raise DisagreementException("Client is closed.")
1486
+
1487
+ await self._http.delete_guild_scheduled_event(guild_id, event_id)
1488
+
1489
+ async def create_invite(
1490
+ self, channel_id: Snowflake, payload: Dict[str, Any]
1491
+ ) -> "Invite":
1492
+ """|coro| Create an invite for the given channel."""
1493
+
1494
+ if self._closed:
1495
+ raise DisagreementException("Client is closed.")
1496
+
1497
+ return await self._http.create_invite(channel_id, payload)
1498
+
1499
+ async def delete_invite(self, code: str) -> None:
1500
+ """|coro| Delete an invite by code."""
1501
+
1502
+ if self._closed:
1503
+ raise DisagreementException("Client is closed.")
1504
+
1505
+ await self._http.delete_invite(code)
1506
+
1507
+ async def fetch_invites(self, channel_id: Snowflake) -> List["Invite"]:
1508
+ """|coro| Fetch all invites for a channel."""
1509
+
1510
+ if self._closed:
1511
+ raise DisagreementException("Client is closed.")
1512
+
1513
+ data = await self._http.get_channel_invites(channel_id)
1514
+ return [self.parse_invite(inv) for inv in data]
1515
+
1516
+ def add_persistent_view(self, view: "View") -> None:
1517
+ """
1518
+ Registers a persistent view with the client.
1519
+
1520
+ Persistent views have a timeout of `None` and their components must have a `custom_id`.
1521
+ This allows the view to be re-instantiated across bot restarts.
1522
+
1523
+ Args:
1524
+ view (View): The view instance to register.
1525
+
1526
+ Raises:
1527
+ ValueError: If the view is not persistent (timeout is not None) or if a component's
1528
+ custom_id is already registered.
1529
+ """
1530
+ if self.is_ready():
1531
+ print(
1532
+ "Warning: Adding a persistent view after the client is ready. "
1533
+ "This view will only be available for interactions on this session."
1534
+ )
1535
+
1536
+ if view.timeout is not None:
1537
+ raise ValueError("Persistent views must have a timeout of None.")
1538
+
1539
+ for item in view.children:
1540
+ if item.custom_id: # Ensure custom_id is not None
1541
+ if item.custom_id in self._persistent_views:
1542
+ raise ValueError(
1543
+ f"A component with custom_id '{item.custom_id}' is already registered."
1544
+ )
1545
+ self._persistent_views[item.custom_id] = view
1546
+
1547
+ # --- Application Command Methods ---
1548
+ async def process_interaction(self, interaction: Interaction) -> None:
1549
+ """Internal method to process an interaction from the gateway."""
1550
+
1551
+ if hasattr(self, "on_interaction_create"):
1552
+ asyncio.create_task(self.on_interaction_create(interaction))
1553
+ # Route component interactions to the appropriate View
1554
+ if (
1555
+ interaction.type == InteractionType.MESSAGE_COMPONENT
1556
+ and interaction.message
1557
+ and interaction.data
1558
+ ):
1559
+ view = self._views.get(interaction.message.id)
1560
+ if view:
1561
+ asyncio.create_task(view._dispatch(interaction))
1562
+ return
1563
+ else:
1564
+ # No active view found, check for persistent views
1565
+ custom_id = interaction.data.custom_id
1566
+ if custom_id:
1567
+ registered_view = self._persistent_views.get(custom_id)
1568
+ if registered_view:
1569
+ # Create a new instance of the persistent view
1570
+ new_view = registered_view.__class__()
1571
+ await new_view._start(self)
1572
+ new_view.message_id = interaction.message.id
1573
+ self._views[interaction.message.id] = new_view
1574
+ asyncio.create_task(new_view._dispatch(interaction))
1575
+ return
1576
+
1577
+ await self.app_command_handler.process_interaction(interaction)
1578
+
1579
+ async def sync_application_commands(
1580
+ self, guild_id: Optional[Snowflake] = None
1581
+ ) -> None:
1582
+ """Synchronizes application commands with Discord."""
1583
+
1584
+ if not self.application_id:
1585
+ print(
1586
+ "Warning: Cannot sync application commands, application_id is not set. "
1587
+ "Ensure the client is connected and READY."
1588
+ )
1589
+ return
1590
+ if not self.is_ready():
1591
+ print(
1592
+ "Warning: Client is not ready. Waiting for client to be ready before syncing commands."
1593
+ )
1594
+ await self.wait_until_ready()
1595
+ if not self.application_id:
1596
+ print(
1597
+ "Error: application_id still not set after client is ready. Cannot sync commands."
1598
+ )
1599
+ return
1600
+
1601
+ await self.app_command_handler.sync_commands(
1602
+ application_id=self.application_id, guild_id=guild_id
1603
+ )
1604
+
1605
+ async def on_interaction_create(self, interaction: Interaction) -> None:
1606
+ """|coro| Called when an interaction is created."""
1607
+
1608
+ pass
1609
+
1610
+ async def on_presence_update(self, presence) -> None:
1611
+ """|coro| Called when a user's presence is updated."""
1612
+
1613
+ pass
1614
+
1615
+ async def on_typing_start(self, typing) -> None:
1616
+ """|coro| Called when a user starts typing in a channel."""
1617
+
1618
+ pass
1619
+
1620
+ async def on_app_command_error(
1621
+ self, context: AppCommandContext, error: Exception
1622
+ ) -> None:
1623
+ """Default error handler for application commands."""
1624
+
1625
+ print(
1626
+ f"Error in application command '{context.command.name if context.command else 'unknown'}': {error}"
1627
+ )
1628
+ try:
1629
+ if not context._responded:
1630
+ await context.send(
1631
+ "An error occurred while running this command.", ephemeral=True
1632
+ )
1633
+ except Exception as e:
1634
+ print(f"Failed to send error message for app command: {e}")
1635
+
1636
+ async def on_error(
1637
+ self, event_method: str, exc: Exception, *args: Any, **kwargs: Any
1638
+ ) -> None:
1639
+ """Default event listener error handler."""
1640
+
1641
+ print(f"Unhandled exception in event listener for '{event_method}':")
1642
+ print(f"{type(exc).__name__}: {exc}")
1643
+
1644
+
1645
+ class AutoShardedClient(Client):
1646
+ """A :class:`Client` that automatically determines the shard count.
1647
+
1648
+ If ``shard_count`` is not provided, the client will query the Discord API
1649
+ via :meth:`HTTPClient.get_gateway_bot` for the recommended shard count and
1650
+ use that when connecting.
1651
+ """
1652
+
1653
+ async def connect(self, reconnect: bool = True) -> None: # type: ignore[override]
1654
+ if self.shard_count is None:
1655
+ data = await self._http.get_gateway_bot()
1656
+ self.shard_count = data.get("shards", 1)
1657
+
1658
+ await super().connect(reconnect=reconnect)