disagreement 0.1.0rc3__py3-none-any.whl → 0.3.0b1__py3-none-any.whl

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