disagreement 0.0.1__py3-none-any.whl

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