disagreement 0.0.1__py3-none-any.whl → 0.1.0rc1__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 (36) hide show
  1. disagreement/__init__.py +8 -3
  2. disagreement/audio.py +116 -0
  3. disagreement/client.py +176 -6
  4. disagreement/color.py +50 -0
  5. disagreement/components.py +2 -2
  6. disagreement/errors.py +13 -8
  7. disagreement/event_dispatcher.py +102 -45
  8. disagreement/ext/__init__.py +0 -0
  9. disagreement/ext/app_commands/__init__.py +46 -0
  10. disagreement/ext/app_commands/commands.py +513 -0
  11. disagreement/ext/app_commands/context.py +556 -0
  12. disagreement/ext/app_commands/converters.py +478 -0
  13. disagreement/ext/app_commands/decorators.py +569 -0
  14. disagreement/ext/app_commands/handler.py +627 -0
  15. disagreement/ext/commands/__init__.py +57 -0
  16. disagreement/ext/commands/cog.py +155 -0
  17. disagreement/ext/commands/converters.py +175 -0
  18. disagreement/ext/commands/core.py +497 -0
  19. disagreement/ext/commands/decorators.py +192 -0
  20. disagreement/ext/commands/errors.py +76 -0
  21. disagreement/ext/commands/help.py +37 -0
  22. disagreement/ext/commands/view.py +103 -0
  23. disagreement/ext/loader.py +54 -0
  24. disagreement/ext/tasks.py +182 -0
  25. disagreement/gateway.py +67 -21
  26. disagreement/http.py +104 -3
  27. disagreement/models.py +308 -1
  28. disagreement/shard_manager.py +2 -0
  29. disagreement/utils.py +10 -0
  30. disagreement/voice_client.py +42 -0
  31. {disagreement-0.0.1.dist-info → disagreement-0.1.0rc1.dist-info}/METADATA +47 -33
  32. disagreement-0.1.0rc1.dist-info/RECORD +52 -0
  33. disagreement-0.0.1.dist-info/RECORD +0 -32
  34. {disagreement-0.0.1.dist-info → disagreement-0.1.0rc1.dist-info}/WHEEL +0 -0
  35. {disagreement-0.0.1.dist-info → disagreement-0.1.0rc1.dist-info}/licenses/LICENSE +0 -0
  36. {disagreement-0.0.1.dist-info → disagreement-0.1.0rc1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,497 @@
1
+ # disagreement/ext/commands/core.py
2
+
3
+ import asyncio
4
+ import inspect
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Optional,
8
+ List,
9
+ Dict,
10
+ Any,
11
+ Union,
12
+ Callable,
13
+ Awaitable,
14
+ Tuple,
15
+ get_origin,
16
+ get_args,
17
+ )
18
+
19
+ from .view import StringView
20
+ from .errors import (
21
+ CommandError,
22
+ CommandNotFound,
23
+ BadArgument,
24
+ MissingRequiredArgument,
25
+ ArgumentParsingError,
26
+ CheckFailure,
27
+ CommandInvokeError,
28
+ )
29
+ from .converters import run_converters, DEFAULT_CONVERTERS, Converter
30
+ from .cog import Cog
31
+ from disagreement.typing import Typing
32
+
33
+ if TYPE_CHECKING:
34
+ from disagreement.client import Client
35
+ from disagreement.models import Message, User
36
+
37
+
38
+ class Command:
39
+ """
40
+ Represents a bot command.
41
+
42
+ Attributes:
43
+ name (str): The primary name of the command.
44
+ callback (Callable[..., Awaitable[None]]): The coroutine function to execute.
45
+ aliases (List[str]): Alternative names for the command.
46
+ brief (Optional[str]): A short description for help commands.
47
+ description (Optional[str]): A longer description for help commands.
48
+ cog (Optional['Cog']): Reference to the Cog this command belongs to.
49
+ params (Dict[str, inspect.Parameter]): Cached parameters of the callback.
50
+ """
51
+
52
+ def __init__(self, callback: Callable[..., Awaitable[None]], **attrs: Any):
53
+ if not asyncio.iscoroutinefunction(callback):
54
+ raise TypeError("Command callback must be a coroutine function.")
55
+
56
+ self.callback: Callable[..., Awaitable[None]] = callback
57
+ self.name: str = attrs.get("name", callback.__name__)
58
+ self.aliases: List[str] = attrs.get("aliases", [])
59
+ self.brief: Optional[str] = attrs.get("brief")
60
+ self.description: Optional[str] = attrs.get("description") or callback.__doc__
61
+ self.cog: Optional["Cog"] = attrs.get("cog")
62
+
63
+ self.params = inspect.signature(callback).parameters
64
+ self.checks: List[Callable[["CommandContext"], Awaitable[bool] | bool]] = []
65
+ if hasattr(callback, "__command_checks__"):
66
+ self.checks.extend(getattr(callback, "__command_checks__"))
67
+
68
+ def add_check(
69
+ self, predicate: Callable[["CommandContext"], Awaitable[bool] | bool]
70
+ ) -> None:
71
+ self.checks.append(predicate)
72
+
73
+ async def invoke(self, ctx: "CommandContext", *args: Any, **kwargs: Any) -> None:
74
+ from .errors import CheckFailure
75
+
76
+ for predicate in self.checks:
77
+ result = predicate(ctx)
78
+ if inspect.isawaitable(result):
79
+ result = await result
80
+ if not result:
81
+ raise CheckFailure("Check predicate failed.")
82
+
83
+ if self.cog:
84
+ await self.callback(self.cog, ctx, *args, **kwargs)
85
+ else:
86
+ await self.callback(ctx, *args, **kwargs)
87
+
88
+
89
+ class CommandContext:
90
+ """
91
+ Represents the context in which a command is being invoked.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ *,
97
+ message: "Message",
98
+ bot: "Client",
99
+ prefix: str,
100
+ command: "Command",
101
+ invoked_with: str,
102
+ args: Optional[List[Any]] = None,
103
+ kwargs: Optional[Dict[str, Any]] = None,
104
+ cog: Optional["Cog"] = None,
105
+ ):
106
+ self.message: "Message" = message
107
+ self.bot: "Client" = bot
108
+ self.prefix: str = prefix
109
+ self.command: "Command" = command
110
+ self.invoked_with: str = invoked_with
111
+ self.args: List[Any] = args or []
112
+ self.kwargs: Dict[str, Any] = kwargs or {}
113
+ self.cog: Optional["Cog"] = cog
114
+
115
+ self.author: "User" = message.author
116
+
117
+ @property
118
+ def guild(self):
119
+ """The guild this command was invoked in."""
120
+ if self.message.guild_id and hasattr(self.bot, "get_guild"):
121
+ return self.bot.get_guild(self.message.guild_id)
122
+ return None
123
+
124
+ async def reply(
125
+ self,
126
+ content: str,
127
+ *,
128
+ mention_author: Optional[bool] = None,
129
+ **kwargs: Any,
130
+ ) -> "Message":
131
+ """Replies to the invoking message.
132
+
133
+ Parameters
134
+ ----------
135
+ content: str
136
+ The content to send.
137
+ mention_author: Optional[bool]
138
+ Whether to mention the author in the reply. If ``None`` the
139
+ client's :attr:`mention_replies` value is used.
140
+ """
141
+
142
+ allowed_mentions = kwargs.pop("allowed_mentions", None)
143
+ if mention_author is None:
144
+ mention_author = getattr(self.bot, "mention_replies", False)
145
+
146
+ if allowed_mentions is None:
147
+ allowed_mentions = {"replied_user": mention_author}
148
+ else:
149
+ allowed_mentions = dict(allowed_mentions)
150
+ allowed_mentions.setdefault("replied_user", mention_author)
151
+
152
+ return await self.bot.send_message(
153
+ channel_id=self.message.channel_id,
154
+ content=content,
155
+ message_reference={
156
+ "message_id": self.message.id,
157
+ "channel_id": self.message.channel_id,
158
+ "guild_id": self.message.guild_id,
159
+ },
160
+ allowed_mentions=allowed_mentions,
161
+ **kwargs,
162
+ )
163
+
164
+ async def send(self, content: str, **kwargs: Any) -> "Message":
165
+ return await self.bot.send_message(
166
+ channel_id=self.message.channel_id, content=content, **kwargs
167
+ )
168
+
169
+ async def edit(
170
+ self,
171
+ message: Union[str, "Message"],
172
+ *,
173
+ content: Optional[str] = None,
174
+ **kwargs: Any,
175
+ ) -> "Message":
176
+ """Edits a message previously sent by the bot."""
177
+
178
+ message_id = message if isinstance(message, str) else message.id
179
+ return await self.bot.edit_message(
180
+ channel_id=self.message.channel_id,
181
+ message_id=message_id,
182
+ content=content,
183
+ **kwargs,
184
+ )
185
+
186
+ def typing(self) -> "Typing":
187
+ """Return a typing context manager for this context's channel."""
188
+
189
+ return self.bot.typing(self.message.channel_id)
190
+
191
+
192
+ class CommandHandler:
193
+ """
194
+ Manages command registration, parsing, and dispatching.
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ client: "Client",
200
+ prefix: Union[
201
+ str, List[str], Callable[["Client", "Message"], Union[str, List[str]]]
202
+ ],
203
+ ):
204
+ self.client: "Client" = client
205
+ self.prefix: Union[
206
+ str, List[str], Callable[["Client", "Message"], Union[str, List[str]]]
207
+ ] = prefix
208
+ self.commands: Dict[str, Command] = {}
209
+ self.cogs: Dict[str, "Cog"] = {}
210
+
211
+ from .help import HelpCommand
212
+
213
+ self.add_command(HelpCommand(self))
214
+
215
+ def add_command(self, command: Command) -> None:
216
+ if command.name in self.commands:
217
+ raise ValueError(f"Command '{command.name}' is already registered.")
218
+
219
+ self.commands[command.name.lower()] = command
220
+ for alias in command.aliases:
221
+ if alias in self.commands:
222
+ print(
223
+ f"Warning: Alias '{alias}' for command '{command.name}' conflicts with an existing command or alias."
224
+ )
225
+ self.commands[alias.lower()] = command
226
+
227
+ def remove_command(self, name: str) -> Optional[Command]:
228
+ command = self.commands.pop(name.lower(), None)
229
+ if command:
230
+ for alias in command.aliases:
231
+ self.commands.pop(alias.lower(), None)
232
+ return command
233
+
234
+ def get_command(self, name: str) -> Optional[Command]:
235
+ return self.commands.get(name.lower())
236
+
237
+ def add_cog(self, cog_to_add: "Cog") -> None:
238
+ if not isinstance(cog_to_add, Cog):
239
+ raise TypeError("Argument must be a subclass of Cog.")
240
+
241
+ if cog_to_add.cog_name in self.cogs:
242
+ raise ValueError(
243
+ f"Cog with name '{cog_to_add.cog_name}' is already registered."
244
+ )
245
+
246
+ self.cogs[cog_to_add.cog_name] = cog_to_add
247
+
248
+ for cmd in cog_to_add.get_commands():
249
+ self.add_command(cmd)
250
+
251
+ if hasattr(self.client, "_event_dispatcher"):
252
+ for event_name, callback in cog_to_add.get_listeners():
253
+ self.client._event_dispatcher.register(event_name.upper(), callback)
254
+ else:
255
+ print(
256
+ f"Warning: Client does not have '_event_dispatcher'. Listeners for cog '{cog_to_add.cog_name}' not registered."
257
+ )
258
+
259
+ if hasattr(cog_to_add, "cog_load") and inspect.iscoroutinefunction(
260
+ cog_to_add.cog_load
261
+ ):
262
+ asyncio.create_task(cog_to_add.cog_load())
263
+
264
+ print(f"Cog '{cog_to_add.cog_name}' added.")
265
+
266
+ def remove_cog(self, cog_name: str) -> Optional["Cog"]:
267
+ cog_to_remove = self.cogs.pop(cog_name, None)
268
+ if cog_to_remove:
269
+ for cmd in cog_to_remove.get_commands():
270
+ self.remove_command(cmd.name)
271
+
272
+ if hasattr(self.client, "_event_dispatcher"):
273
+ for event_name, callback in cog_to_remove.get_listeners():
274
+ print(
275
+ f"Note: Listener '{callback.__name__}' for event '{event_name}' from cog '{cog_name}' needs manual unregistration logic in EventDispatcher."
276
+ )
277
+
278
+ if hasattr(cog_to_remove, "cog_unload") and inspect.iscoroutinefunction(
279
+ cog_to_remove.cog_unload
280
+ ):
281
+ asyncio.create_task(cog_to_remove.cog_unload())
282
+
283
+ cog_to_remove._eject()
284
+ print(f"Cog '{cog_name}' removed.")
285
+ return cog_to_remove
286
+
287
+ async def get_prefix(self, message: "Message") -> Union[str, List[str], None]:
288
+ if callable(self.prefix):
289
+ if inspect.iscoroutinefunction(self.prefix):
290
+ return await self.prefix(self.client, message)
291
+ else:
292
+ return self.prefix(self.client, message) # type: ignore
293
+ return self.prefix
294
+
295
+ async def _parse_arguments(
296
+ self, command: Command, ctx: CommandContext, view: StringView
297
+ ) -> Tuple[List[Any], Dict[str, Any]]:
298
+ args_list = []
299
+ kwargs_dict = {}
300
+ params_to_parse = list(command.params.values())
301
+
302
+ if params_to_parse and params_to_parse[0].name == "self" and command.cog:
303
+ params_to_parse.pop(0)
304
+ if params_to_parse and params_to_parse[0].name == "ctx":
305
+ params_to_parse.pop(0)
306
+
307
+ for param in params_to_parse:
308
+ view.skip_whitespace()
309
+ final_value_for_param: Any = inspect.Parameter.empty
310
+
311
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
312
+ while not view.eof:
313
+ view.skip_whitespace()
314
+ if view.eof:
315
+ break
316
+ word = view.get_word()
317
+ if word or not view.eof:
318
+ args_list.append(word)
319
+ elif view.eof:
320
+ break
321
+ break
322
+
323
+ arg_str_value: Optional[str] = (
324
+ None # Holds the raw string for current param
325
+ )
326
+
327
+ if view.eof: # No more input string
328
+ if param.default is not inspect.Parameter.empty:
329
+ final_value_for_param = param.default
330
+ elif param.kind != inspect.Parameter.VAR_KEYWORD:
331
+ raise MissingRequiredArgument(param.name)
332
+ else: # VAR_KEYWORD at EOF is fine
333
+ break
334
+ else: # Input available
335
+ is_last_pos_str_greedy = (
336
+ param == params_to_parse[-1]
337
+ and param.annotation is str
338
+ and param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD
339
+ )
340
+
341
+ if is_last_pos_str_greedy:
342
+ arg_str_value = view.read_rest().strip()
343
+ if (
344
+ not arg_str_value
345
+ and param.default is not inspect.Parameter.empty
346
+ ):
347
+ final_value_for_param = param.default
348
+ else: # Includes empty string if that's what's left
349
+ final_value_for_param = arg_str_value
350
+ else: # Not greedy, or not string, or not last positional
351
+ if view.buffer[view.index] == '"':
352
+ arg_str_value = view.get_quoted_string()
353
+ if arg_str_value == "" and view.buffer[view.index] == '"':
354
+ raise BadArgument(
355
+ f"Unterminated quoted string for argument '{param.name}'."
356
+ )
357
+ else:
358
+ arg_str_value = view.get_word()
359
+
360
+ # If final_value_for_param was not set by greedy logic, try conversion
361
+ if final_value_for_param is inspect.Parameter.empty:
362
+ if (
363
+ arg_str_value is None
364
+ ): # Should not happen if view.get_word/get_quoted_string is robust
365
+ if param.default is not inspect.Parameter.empty:
366
+ final_value_for_param = param.default
367
+ else:
368
+ raise MissingRequiredArgument(param.name)
369
+ else: # We have an arg_str_value (could be empty string "" from quotes)
370
+ annotation = param.annotation
371
+ origin = get_origin(annotation)
372
+
373
+ if origin is Union: # Handles Optional[T] and Union[T1, T2]
374
+ union_args = get_args(annotation)
375
+ is_optional = (
376
+ len(union_args) == 2 and type(None) in union_args
377
+ )
378
+
379
+ converted_for_union = False
380
+ last_err_union: Optional[BadArgument] = None
381
+ for t_arg in union_args:
382
+ if t_arg is type(None):
383
+ continue
384
+ try:
385
+ final_value_for_param = await run_converters(
386
+ ctx, t_arg, arg_str_value
387
+ )
388
+ converted_for_union = True
389
+ break
390
+ except BadArgument as e:
391
+ last_err_union = e
392
+
393
+ if not converted_for_union:
394
+ if (
395
+ is_optional and param.default is None
396
+ ): # Special handling for Optional[T] if conversion failed
397
+ # If arg_str_value was "" and type was Optional[str], StringConverter would return ""
398
+ # If arg_str_value was "" and type was Optional[int], BadArgument would be raised.
399
+ # This path is for when all actual types in Optional[T] fail conversion.
400
+ # If default is None, we can assign None.
401
+ final_value_for_param = None
402
+ elif last_err_union:
403
+ raise last_err_union
404
+ else: # Should not be reached if logic is correct
405
+ raise BadArgument(
406
+ f"Could not convert '{arg_str_value}' to any of {union_args} for param '{param.name}'."
407
+ )
408
+ elif annotation is inspect.Parameter.empty or annotation is str:
409
+ final_value_for_param = arg_str_value
410
+ else: # Standard type hint
411
+ final_value_for_param = await run_converters(
412
+ ctx, annotation, arg_str_value
413
+ )
414
+
415
+ # Final check if value was resolved
416
+ if final_value_for_param is inspect.Parameter.empty:
417
+ if param.default is not inspect.Parameter.empty:
418
+ final_value_for_param = param.default
419
+ elif param.kind != inspect.Parameter.VAR_KEYWORD:
420
+ # This state implies an issue if required and no default, and no input was parsed.
421
+ raise MissingRequiredArgument(
422
+ f"Parameter '{param.name}' could not be resolved."
423
+ )
424
+
425
+ # Assign to args_list or kwargs_dict if a value was determined
426
+ if final_value_for_param is not inspect.Parameter.empty:
427
+ if (
428
+ param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD
429
+ or param.kind == inspect.Parameter.POSITIONAL_ONLY
430
+ ):
431
+ args_list.append(final_value_for_param)
432
+ elif param.kind == inspect.Parameter.KEYWORD_ONLY:
433
+ kwargs_dict[param.name] = final_value_for_param
434
+
435
+ return args_list, kwargs_dict
436
+
437
+ async def process_commands(self, message: "Message") -> None:
438
+ if not message.content:
439
+ return
440
+
441
+ prefix_to_use = await self.get_prefix(message)
442
+ if not prefix_to_use:
443
+ return
444
+
445
+ actual_prefix: Optional[str] = None
446
+ if isinstance(prefix_to_use, list):
447
+ for p in prefix_to_use:
448
+ if message.content.startswith(p):
449
+ actual_prefix = p
450
+ break
451
+ if not actual_prefix:
452
+ return
453
+ elif isinstance(prefix_to_use, str):
454
+ if message.content.startswith(prefix_to_use):
455
+ actual_prefix = prefix_to_use
456
+ else:
457
+ return
458
+ else:
459
+ return
460
+
461
+ if actual_prefix is None:
462
+ return
463
+
464
+ content_without_prefix = message.content[len(actual_prefix) :]
465
+ view = StringView(content_without_prefix)
466
+
467
+ command_name = view.get_word()
468
+ if not command_name:
469
+ return
470
+
471
+ command = self.get_command(command_name)
472
+ if not command:
473
+ return
474
+
475
+ ctx = CommandContext(
476
+ message=message,
477
+ bot=self.client,
478
+ prefix=actual_prefix,
479
+ command=command,
480
+ invoked_with=command_name,
481
+ cog=command.cog,
482
+ )
483
+
484
+ try:
485
+ parsed_args, parsed_kwargs = await self._parse_arguments(command, ctx, view)
486
+ ctx.args = parsed_args
487
+ ctx.kwargs = parsed_kwargs
488
+ await command.invoke(ctx, *parsed_args, **parsed_kwargs)
489
+ except CommandError as e:
490
+ print(f"Command error for '{command.name}': {e}")
491
+ if hasattr(self.client, "on_command_error"):
492
+ await self.client.on_command_error(ctx, e)
493
+ except Exception as e:
494
+ print(f"Unexpected error invoking command '{command.name}': {e}")
495
+ exc = CommandInvokeError(e)
496
+ if hasattr(self.client, "on_command_error"):
497
+ await self.client.on_command_error(ctx, exc)
@@ -0,0 +1,192 @@
1
+ # disagreement/ext/commands/decorators.py
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import inspect
6
+ import time
7
+ from typing import Callable, Any, Optional, List, TYPE_CHECKING, Awaitable
8
+
9
+ if TYPE_CHECKING:
10
+ from .core import Command, CommandContext
11
+ from disagreement.permissions import Permissions
12
+ from disagreement.models import Member, Guild, Channel
13
+
14
+
15
+ def command(
16
+ name: Optional[str] = None, aliases: Optional[List[str]] = None, **attrs: Any
17
+ ) -> Callable:
18
+ """
19
+ A decorator that transforms a function into a Command.
20
+
21
+ Args:
22
+ name (Optional[str]): The name of the command. Defaults to the function name.
23
+ aliases (Optional[List[str]]): Alternative names for the command.
24
+ **attrs: Additional attributes to pass to the Command constructor
25
+ (e.g., brief, description, hidden).
26
+
27
+ Returns:
28
+ Callable: A decorator that registers the command.
29
+ """
30
+
31
+ def decorator(
32
+ func: Callable[..., Awaitable[None]],
33
+ ) -> Callable[..., Awaitable[None]]:
34
+ if not asyncio.iscoroutinefunction(func):
35
+ raise TypeError("Command callback must be a coroutine function.")
36
+
37
+ from .core import Command
38
+
39
+ cmd_name = name or func.__name__
40
+
41
+ if hasattr(func, "__command_attrs__"):
42
+ raise TypeError("Function is already a command or has command attributes.")
43
+
44
+ cmd = Command(callback=func, name=cmd_name, aliases=aliases or [], **attrs)
45
+ func.__command_object__ = cmd # type: ignore
46
+ return func
47
+
48
+ return decorator
49
+
50
+
51
+ def listener(
52
+ name: Optional[str] = None,
53
+ ) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
54
+ """
55
+ A decorator that marks a function as an event listener within a Cog.
56
+ """
57
+
58
+ def decorator(
59
+ func: Callable[..., Awaitable[None]],
60
+ ) -> Callable[..., Awaitable[None]]:
61
+ if not asyncio.iscoroutinefunction(func):
62
+ raise TypeError("Listener callback must be a coroutine function.")
63
+
64
+ actual_event_name = name or func.__name__
65
+ setattr(func, "__listener_name__", actual_event_name)
66
+ return func
67
+
68
+ return decorator
69
+
70
+
71
+ def check(
72
+ predicate: Callable[["CommandContext"], Awaitable[bool] | bool],
73
+ ) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
74
+ """Decorator to add a check to a command."""
75
+
76
+ def decorator(
77
+ func: Callable[..., Awaitable[None]],
78
+ ) -> Callable[..., Awaitable[None]]:
79
+ checks = getattr(func, "__command_checks__", [])
80
+ checks.append(predicate)
81
+ setattr(func, "__command_checks__", checks)
82
+ return func
83
+
84
+ return decorator
85
+
86
+
87
+ def check_any(
88
+ *predicates: Callable[["CommandContext"], Awaitable[bool] | bool]
89
+ ) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
90
+ """Decorator that passes if any predicate returns ``True``."""
91
+
92
+ async def predicate(ctx: "CommandContext") -> bool:
93
+ from .errors import CheckAnyFailure, CheckFailure
94
+
95
+ errors = []
96
+ for p in predicates:
97
+ try:
98
+ result = p(ctx)
99
+ if inspect.isawaitable(result):
100
+ result = await result
101
+ if result:
102
+ return True
103
+ except CheckFailure as e:
104
+ errors.append(e)
105
+ raise CheckAnyFailure(errors)
106
+
107
+ return check(predicate)
108
+
109
+
110
+ def cooldown(
111
+ rate: int, per: float
112
+ ) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
113
+ """Simple per-user cooldown decorator."""
114
+
115
+ buckets: dict[str, dict[str, float]] = {}
116
+
117
+ async def predicate(ctx: "CommandContext") -> bool:
118
+ from .errors import CommandOnCooldown
119
+
120
+ now = time.monotonic()
121
+ user_buckets = buckets.setdefault(ctx.command.name, {})
122
+ reset = user_buckets.get(ctx.author.id, 0)
123
+ if now < reset:
124
+ raise CommandOnCooldown(reset - now)
125
+ user_buckets[ctx.author.id] = now + per
126
+ return True
127
+
128
+ return check(predicate)
129
+
130
+
131
+ def _compute_permissions(
132
+ member: "Member", channel: "Channel", guild: "Guild"
133
+ ) -> "Permissions":
134
+ """Compute the effective permissions for a member in a channel."""
135
+ return channel.permissions_for(member)
136
+
137
+
138
+ def requires_permissions(
139
+ *perms: "Permissions",
140
+ ) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
141
+ """Check that the invoking member has the given permissions in the channel."""
142
+
143
+ async def predicate(ctx: "CommandContext") -> bool:
144
+ from .errors import CheckFailure
145
+ from disagreement.permissions import (
146
+ has_permissions,
147
+ missing_permissions,
148
+ )
149
+ from disagreement.models import Member
150
+
151
+ channel = getattr(ctx, "channel", None)
152
+ if channel is None and hasattr(ctx.bot, "get_channel"):
153
+ channel = ctx.bot.get_channel(ctx.message.channel_id)
154
+ if channel is None and hasattr(ctx.bot, "fetch_channel"):
155
+ channel = await ctx.bot.fetch_channel(ctx.message.channel_id)
156
+
157
+ if channel is None:
158
+ raise CheckFailure("Channel for permission check not found.")
159
+
160
+ guild = getattr(channel, "guild", None)
161
+ if not guild and hasattr(channel, "guild_id") and channel.guild_id:
162
+ if hasattr(ctx.bot, "get_guild"):
163
+ guild = ctx.bot.get_guild(channel.guild_id)
164
+ if not guild and hasattr(ctx.bot, "fetch_guild"):
165
+ guild = await ctx.bot.fetch_guild(channel.guild_id)
166
+
167
+ if not guild:
168
+ is_dm = not hasattr(channel, "guild_id") or not channel.guild_id
169
+ if is_dm:
170
+ if perms:
171
+ raise CheckFailure("Permission checks are not supported in DMs.")
172
+ return True
173
+ raise CheckFailure("Guild for permission check not found.")
174
+
175
+ member = ctx.author
176
+ if not isinstance(member, Member):
177
+ member = guild.get_member(ctx.author.id)
178
+ if not member and hasattr(ctx.bot, "fetch_member"):
179
+ member = await ctx.bot.fetch_member(guild.id, ctx.author.id)
180
+
181
+ if not member:
182
+ raise CheckFailure("Could not resolve author to a guild member.")
183
+
184
+ perms_value = _compute_permissions(member, channel, guild)
185
+
186
+ if not has_permissions(perms_value, *perms):
187
+ missing = missing_permissions(perms_value, *perms)
188
+ missing_names = ", ".join(p.name for p in missing if p.name)
189
+ raise CheckFailure(f"Missing permissions: {missing_names}")
190
+ return True
191
+
192
+ return check(predicate)