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