disagreement 0.1.0rc1__py3-none-any.whl → 0.1.0rc2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- disagreement/__init__.py +2 -2
- disagreement/client.py +41 -2
- disagreement/color.py +28 -0
- disagreement/enums.py +5 -0
- disagreement/ext/app_commands/__init__.py +2 -0
- disagreement/ext/app_commands/commands.py +13 -99
- disagreement/ext/app_commands/decorators.py +1 -1
- disagreement/ext/app_commands/hybrid.py +61 -0
- disagreement/ext/commands/core.py +8 -2
- disagreement/ext/tasks.py +46 -0
- disagreement/http.py +115 -13
- disagreement/interactions.py +17 -14
- disagreement/models.py +124 -6
- disagreement/ui/modal.py +1 -1
- disagreement/utils.py +63 -0
- {disagreement-0.1.0rc1.dist-info → disagreement-0.1.0rc2.dist-info}/METADATA +6 -5
- {disagreement-0.1.0rc1.dist-info → disagreement-0.1.0rc2.dist-info}/RECORD +20 -19
- {disagreement-0.1.0rc1.dist-info → disagreement-0.1.0rc2.dist-info}/WHEEL +0 -0
- {disagreement-0.1.0rc1.dist-info → disagreement-0.1.0rc2.dist-info}/licenses/LICENSE +0 -0
- {disagreement-0.1.0rc1.dist-info → disagreement-0.1.0rc2.dist-info}/top_level.txt +0 -0
disagreement/__init__.py
CHANGED
@@ -14,7 +14,7 @@ __title__ = "disagreement"
|
|
14
14
|
__author__ = "Slipstream"
|
15
15
|
__license__ = "BSD 3-Clause License"
|
16
16
|
__copyright__ = "Copyright 2025 Slipstream"
|
17
|
-
__version__ = "0.1.
|
17
|
+
__version__ = "0.1.0rc2"
|
18
18
|
|
19
19
|
from .client import Client, AutoShardedClient
|
20
20
|
from .models import Message, User, Reaction
|
@@ -30,7 +30,7 @@ from .errors import (
|
|
30
30
|
NotFound,
|
31
31
|
)
|
32
32
|
from .color import Color
|
33
|
-
from .utils import utcnow
|
33
|
+
from .utils import utcnow, message_pager
|
34
34
|
from .enums import GatewayIntent, GatewayOpcode # Export enums
|
35
35
|
from .error_handler import setup_global_error_handler
|
36
36
|
from .hybrid_context import HybridContext
|
disagreement/client.py
CHANGED
@@ -16,6 +16,7 @@ from typing import (
|
|
16
16
|
List,
|
17
17
|
Dict,
|
18
18
|
)
|
19
|
+
from types import ModuleType
|
19
20
|
|
20
21
|
from .http import HTTPClient
|
21
22
|
from .gateway import GatewayClient
|
@@ -28,6 +29,7 @@ from .ext.commands.core import CommandHandler
|
|
28
29
|
from .ext.commands.cog import Cog
|
29
30
|
from .ext.app_commands.handler import AppCommandHandler
|
30
31
|
from .ext.app_commands.context import AppCommandContext
|
32
|
+
from .ext import loader as ext_loader
|
31
33
|
from .interactions import Interaction, Snowflake
|
32
34
|
from .error_handler import setup_global_error_handler
|
33
35
|
from .voice_client import VoiceClient
|
@@ -638,6 +640,23 @@ class Client:
|
|
638
640
|
# import traceback
|
639
641
|
# traceback.print_exception(type(error.original), error.original, error.original.__traceback__)
|
640
642
|
|
643
|
+
# --- Extension Management Methods ---
|
644
|
+
|
645
|
+
def load_extension(self, name: str) -> ModuleType:
|
646
|
+
"""Load an extension by name using :mod:`disagreement.ext.loader`."""
|
647
|
+
|
648
|
+
return ext_loader.load_extension(name)
|
649
|
+
|
650
|
+
def unload_extension(self, name: str) -> None:
|
651
|
+
"""Unload a previously loaded extension."""
|
652
|
+
|
653
|
+
ext_loader.unload_extension(name)
|
654
|
+
|
655
|
+
def reload_extension(self, name: str) -> ModuleType:
|
656
|
+
"""Reload an extension by name."""
|
657
|
+
|
658
|
+
return ext_loader.reload_extension(name)
|
659
|
+
|
641
660
|
# --- Model Parsing and Fetching ---
|
642
661
|
|
643
662
|
def parse_user(self, data: Dict[str, Any]) -> "User":
|
@@ -920,12 +939,12 @@ class Client:
|
|
920
939
|
await view._start(self)
|
921
940
|
components_payload = view.to_components_payload()
|
922
941
|
elif components:
|
923
|
-
from .models import
|
942
|
+
from .models import Component as ComponentModel
|
924
943
|
|
925
944
|
components_payload = [
|
926
945
|
comp.to_dict()
|
927
946
|
for comp in components
|
928
|
-
if isinstance(comp,
|
947
|
+
if isinstance(comp, ComponentModel)
|
929
948
|
]
|
930
949
|
|
931
950
|
message_data = await self._http.send_message(
|
@@ -1016,6 +1035,26 @@ class Client:
|
|
1016
1035
|
self._voice_clients[guild_id] = voice
|
1017
1036
|
return voice
|
1018
1037
|
|
1038
|
+
async def add_reaction(self, channel_id: str, message_id: str, emoji: str) -> None:
|
1039
|
+
"""|coro| Add a reaction to a message."""
|
1040
|
+
|
1041
|
+
await self.create_reaction(channel_id, message_id, emoji)
|
1042
|
+
|
1043
|
+
async def remove_reaction(
|
1044
|
+
self, channel_id: str, message_id: str, emoji: str
|
1045
|
+
) -> None:
|
1046
|
+
"""|coro| Remove the bot's reaction from a message."""
|
1047
|
+
|
1048
|
+
await self.delete_reaction(channel_id, message_id, emoji)
|
1049
|
+
|
1050
|
+
async def clear_reactions(self, channel_id: str, message_id: str) -> None:
|
1051
|
+
"""|coro| Remove all reactions from a message."""
|
1052
|
+
|
1053
|
+
if self._closed:
|
1054
|
+
raise DisagreementException("Client is closed.")
|
1055
|
+
|
1056
|
+
await self._http.clear_reactions(channel_id, message_id)
|
1057
|
+
|
1019
1058
|
async def create_reaction(
|
1020
1059
|
self, channel_id: str, message_id: str, emoji: str
|
1021
1060
|
) -> None:
|
disagreement/color.py
CHANGED
@@ -48,3 +48,31 @@ class Color:
|
|
48
48
|
|
49
49
|
def to_rgb(self) -> tuple[int, int, int]:
|
50
50
|
return ((self.value >> 16) & 0xFF, (self.value >> 8) & 0xFF, self.value & 0xFF)
|
51
|
+
|
52
|
+
@classmethod
|
53
|
+
def parse(cls, value: "Color | int | str | tuple[int, int, int] | None") -> "Color | None":
|
54
|
+
"""Convert ``value`` to a :class:`Color` instance.
|
55
|
+
|
56
|
+
Parameters
|
57
|
+
----------
|
58
|
+
value:
|
59
|
+
The value to convert. May be ``None``, an existing ``Color``, an
|
60
|
+
integer in the ``0xRRGGBB`` format, or a hex string like ``"#RRGGBB"``.
|
61
|
+
|
62
|
+
Returns
|
63
|
+
-------
|
64
|
+
Optional[Color]
|
65
|
+
A ``Color`` object if ``value`` is not ``None``.
|
66
|
+
"""
|
67
|
+
|
68
|
+
if value is None:
|
69
|
+
return None
|
70
|
+
if isinstance(value, cls):
|
71
|
+
return value
|
72
|
+
if isinstance(value, int):
|
73
|
+
return cls(value)
|
74
|
+
if isinstance(value, str):
|
75
|
+
return cls.from_hex(value)
|
76
|
+
if isinstance(value, tuple) and len(value) == 3:
|
77
|
+
return cls.from_rgb(*value)
|
78
|
+
raise TypeError("Color value must be Color, int, str, tuple, or None")
|
disagreement/enums.py
CHANGED
@@ -49,6 +49,11 @@ class GatewayIntent(IntEnum):
|
|
49
49
|
AUTO_MODERATION_CONFIGURATION = 1 << 20
|
50
50
|
AUTO_MODERATION_EXECUTION = 1 << 21
|
51
51
|
|
52
|
+
@classmethod
|
53
|
+
def none(cls) -> int:
|
54
|
+
"""Return a bitmask representing no intents."""
|
55
|
+
return 0
|
56
|
+
|
52
57
|
@classmethod
|
53
58
|
def default(cls) -> int:
|
54
59
|
"""Returns default intents (excluding privileged ones like members, presences, message content)."""
|
@@ -1,58 +1,25 @@
|
|
1
1
|
# disagreement/ext/app_commands/commands.py
|
2
2
|
|
3
3
|
import inspect
|
4
|
-
from typing import Callable,
|
4
|
+
from typing import Any, Callable, Dict, List, Optional, Union, TYPE_CHECKING
|
5
5
|
|
6
|
+
from disagreement.enums import (
|
7
|
+
ApplicationCommandType,
|
8
|
+
ApplicationCommandOptionType,
|
9
|
+
IntegrationType,
|
10
|
+
InteractionContextType,
|
11
|
+
)
|
12
|
+
from disagreement.interactions import ApplicationCommandOption, Snowflake
|
6
13
|
|
7
14
|
if TYPE_CHECKING:
|
8
|
-
from disagreement.ext.commands.core import
|
9
|
-
|
10
|
-
|
11
|
-
from disagreement.interactions import ApplicationCommandOption, Snowflake
|
12
|
-
from disagreement.enums import (
|
13
|
-
ApplicationCommandType,
|
14
|
-
IntegrationType,
|
15
|
-
InteractionContextType,
|
16
|
-
ApplicationCommandOptionType, # Added
|
17
|
-
)
|
18
|
-
from disagreement.ext.commands.cog import Cog # Corrected import path
|
19
|
-
|
20
|
-
# Placeholder for Cog if not using the existing one or if it needs adaptation
|
15
|
+
from disagreement.ext.commands.core import Command as PrefixCommand
|
16
|
+
from disagreement.ext.commands.cog import Cog
|
17
|
+
|
21
18
|
if not TYPE_CHECKING:
|
22
|
-
# This dynamic Cog = Any might not be ideal if Cog is used in runtime type checks.
|
23
|
-
# However, for type hinting purposes when TYPE_CHECKING is false, it avoids import.
|
24
|
-
# If Cog is needed at runtime by this module (it is, for AppCommand.cog type hint),
|
25
|
-
# it should be imported directly.
|
26
|
-
# For now, the TYPE_CHECKING block handles the proper import for static analysis.
|
27
|
-
# Let's ensure Cog is available at runtime if AppCommand.cog is accessed.
|
28
|
-
# A simple way is to import it outside TYPE_CHECKING too, if it doesn't cause circularity.
|
29
|
-
# Given its usage, a forward reference string 'Cog' might be better in AppCommand.cog type hint.
|
30
|
-
# Let's try importing it directly for runtime, assuming no circularity with this specific module.
|
31
|
-
try:
|
32
|
-
from disagreement.ext.commands.cog import Cog
|
33
|
-
except ImportError:
|
34
|
-
Cog = Any # Fallback if direct import fails (e.g. during partial builds/tests)
|
35
|
-
# Import PrefixCommand at runtime for HybridCommand
|
36
19
|
try:
|
37
20
|
from disagreement.ext.commands.core import Command as PrefixCommand
|
38
|
-
except
|
39
|
-
PrefixCommand = Any
|
40
|
-
# Import enums used at runtime
|
41
|
-
try:
|
42
|
-
from disagreement.enums import (
|
43
|
-
ApplicationCommandType,
|
44
|
-
IntegrationType,
|
45
|
-
InteractionContextType,
|
46
|
-
ApplicationCommandOptionType,
|
47
|
-
)
|
48
|
-
from disagreement.interactions import ApplicationCommandOption, Snowflake
|
49
|
-
except Exception: # pragma: no cover
|
50
|
-
ApplicationCommandType = ApplicationCommandOptionType = IntegrationType = (
|
51
|
-
InteractionContextType
|
52
|
-
) = Any # type: ignore
|
53
|
-
ApplicationCommandOption = Snowflake = Any # type: ignore
|
54
|
-
else: # When TYPE_CHECKING is true, Cog and PrefixCommand are already imported above.
|
55
|
-
pass
|
21
|
+
except ImportError:
|
22
|
+
PrefixCommand = Any
|
56
23
|
|
57
24
|
|
58
25
|
class AppCommand:
|
@@ -235,59 +202,6 @@ class MessageCommand(AppCommand):
|
|
235
202
|
super().__init__(callback, type=ApplicationCommandType.MESSAGE, **kwargs)
|
236
203
|
|
237
204
|
|
238
|
-
class HybridCommand(SlashCommand, PrefixCommand): # Inherit from both
|
239
|
-
"""
|
240
|
-
Represents a command that can be invoked as both a slash command
|
241
|
-
and a traditional prefix-based command.
|
242
|
-
"""
|
243
|
-
|
244
|
-
def __init__(self, callback: Callable[..., Any], **kwargs: Any):
|
245
|
-
# Initialize SlashCommand part (which calls AppCommand.__init__)
|
246
|
-
# We need to ensure 'type' is correctly passed for AppCommand
|
247
|
-
# kwargs for SlashCommand: name, description, guild_ids, default_member_permissions, nsfw, parent, cog, etc.
|
248
|
-
# kwargs for PrefixCommand: name, aliases, brief, description, cog
|
249
|
-
|
250
|
-
# Pop prefix-specific args before passing to SlashCommand constructor
|
251
|
-
prefix_aliases = kwargs.pop("aliases", [])
|
252
|
-
prefix_brief = kwargs.pop("brief", None)
|
253
|
-
# Description is used by both, AppCommand's constructor will handle it.
|
254
|
-
# Name is used by both. Cog is used by both.
|
255
|
-
|
256
|
-
# Call SlashCommand's __init__
|
257
|
-
# This will set up name, description, callback, type=CHAT_INPUT, options, etc.
|
258
|
-
super().__init__(callback, **kwargs) # This is SlashCommand.__init__
|
259
|
-
|
260
|
-
# Now, explicitly initialize the PrefixCommand parts that SlashCommand didn't cover
|
261
|
-
# or that need specific values for the prefix version.
|
262
|
-
# PrefixCommand.__init__(self, callback, name=self.name, aliases=prefix_aliases, brief=prefix_brief, description=self.description, cog=self.cog)
|
263
|
-
# However, PrefixCommand.__init__ also sets self.params, which AppCommand already did.
|
264
|
-
# We need to be careful not to re-initialize things unnecessarily or incorrectly.
|
265
|
-
# Let's manually set the distinct attributes for the PrefixCommand aspect.
|
266
|
-
|
267
|
-
# Attributes from PrefixCommand:
|
268
|
-
# self.callback is already set by AppCommand
|
269
|
-
# self.name is already set by AppCommand
|
270
|
-
self.aliases: List[str] = (
|
271
|
-
prefix_aliases # This was specific to HybridCommand before, now aligns with PrefixCommand
|
272
|
-
)
|
273
|
-
self.brief: Optional[str] = prefix_brief
|
274
|
-
# self.description is already set by AppCommand (SlashCommand ensures it exists)
|
275
|
-
# self.cog is already set by AppCommand
|
276
|
-
# self.params is already set by AppCommand
|
277
|
-
|
278
|
-
# Ensure the MRO is handled correctly. Python's MRO (C3 linearization)
|
279
|
-
# should call SlashCommand's __init__ then AppCommand's __init__.
|
280
|
-
# PrefixCommand.__init__ won't be called automatically unless we explicitly call it.
|
281
|
-
# By setting attributes directly, we avoid potential issues with multiple __init__ calls
|
282
|
-
# if their logic overlaps too much (e.g., both trying to set self.params).
|
283
|
-
|
284
|
-
# We might need to override invoke if the context or argument passing differs significantly
|
285
|
-
# between app command invocation and prefix command invocation.
|
286
|
-
# For now, SlashCommand.invoke and PrefixCommand.invoke are separate.
|
287
|
-
# The correct one will be called depending on how the command is dispatched.
|
288
|
-
# The AppCommandHandler will use AppCommand.invoke (via SlashCommand).
|
289
|
-
# The prefix CommandHandler will use PrefixCommand.invoke.
|
290
|
-
# This seems acceptable.
|
291
205
|
|
292
206
|
|
293
207
|
class AppCommandGroup:
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# disagreement/ext/app_commands/hybrid.py
|
2
|
+
|
3
|
+
from typing import Any, Callable, List, Optional
|
4
|
+
|
5
|
+
from .commands import SlashCommand
|
6
|
+
from disagreement.ext.commands.core import PrefixCommand
|
7
|
+
|
8
|
+
|
9
|
+
class HybridCommand(SlashCommand, PrefixCommand): # Inherit from both
|
10
|
+
"""
|
11
|
+
Represents a command that can be invoked as both a slash command
|
12
|
+
and a traditional prefix-based command.
|
13
|
+
"""
|
14
|
+
|
15
|
+
def __init__(self, callback: Callable[..., Any], **kwargs: Any):
|
16
|
+
# Initialize SlashCommand part (which calls AppCommand.__init__)
|
17
|
+
# We need to ensure 'type' is correctly passed for AppCommand
|
18
|
+
# kwargs for SlashCommand: name, description, guild_ids, default_member_permissions, nsfw, parent, cog, etc.
|
19
|
+
# kwargs for PrefixCommand: name, aliases, brief, description, cog
|
20
|
+
|
21
|
+
# Pop prefix-specific args before passing to SlashCommand constructor
|
22
|
+
prefix_aliases = kwargs.pop("aliases", [])
|
23
|
+
prefix_brief = kwargs.pop("brief", None)
|
24
|
+
# Description is used by both, AppCommand's constructor will handle it.
|
25
|
+
# Name is used by both. Cog is used by both.
|
26
|
+
|
27
|
+
# Call SlashCommand's __init__
|
28
|
+
# This will set up name, description, callback, type=CHAT_INPUT, options, etc.
|
29
|
+
super().__init__(callback, **kwargs) # This is SlashCommand.__init__
|
30
|
+
|
31
|
+
# Now, explicitly initialize the PrefixCommand parts that SlashCommand didn't cover
|
32
|
+
# or that need specific values for the prefix version.
|
33
|
+
# PrefixCommand.__init__(self, callback, name=self.name, aliases=prefix_aliases, brief=prefix_brief, description=self.description, cog=self.cog)
|
34
|
+
# However, PrefixCommand.__init__ also sets self.params, which AppCommand already did.
|
35
|
+
# We need to be careful not to re-initialize things unnecessarily or incorrectly.
|
36
|
+
# Let's manually set the distinct attributes for the PrefixCommand aspect.
|
37
|
+
|
38
|
+
# Attributes from PrefixCommand:
|
39
|
+
# self.callback is already set by AppCommand
|
40
|
+
# self.name is already set by AppCommand
|
41
|
+
self.aliases: List[str] = (
|
42
|
+
prefix_aliases # This was specific to HybridCommand before, now aligns with PrefixCommand
|
43
|
+
)
|
44
|
+
self.brief: Optional[str] = prefix_brief
|
45
|
+
# self.description is already set by AppCommand (SlashCommand ensures it exists)
|
46
|
+
# self.cog is already set by AppCommand
|
47
|
+
# self.params is already set by AppCommand
|
48
|
+
|
49
|
+
# Ensure the MRO is handled correctly. Python's MRO (C3 linearization)
|
50
|
+
# should call SlashCommand's __init__ then AppCommand's __init__.
|
51
|
+
# PrefixCommand.__init__ won't be called automatically unless we explicitly call it.
|
52
|
+
# By setting attributes directly, we avoid potential issues with multiple __init__ calls
|
53
|
+
# if their logic overlaps too much (e.g., both trying to set self.params).
|
54
|
+
|
55
|
+
# We might need to override invoke if the context or argument passing differs significantly
|
56
|
+
# between app command invocation and prefix command invocation.
|
57
|
+
# For now, SlashCommand.invoke and PrefixCommand.invoke are separate.
|
58
|
+
# The correct one will be called depending on how the command is dispatched.
|
59
|
+
# The AppCommandHandler will use AppCommand.invoke (via SlashCommand).
|
60
|
+
# The prefix CommandHandler will use PrefixCommand.invoke.
|
61
|
+
# This seems acceptable.
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# disagreement/ext/commands/core.py
|
2
2
|
|
3
|
+
from __future__ import annotations
|
4
|
+
|
3
5
|
import asyncio
|
4
6
|
import inspect
|
5
7
|
from typing import (
|
@@ -27,10 +29,10 @@ from .errors import (
|
|
27
29
|
CommandInvokeError,
|
28
30
|
)
|
29
31
|
from .converters import run_converters, DEFAULT_CONVERTERS, Converter
|
30
|
-
from .cog import Cog
|
31
32
|
from disagreement.typing import Typing
|
32
33
|
|
33
34
|
if TYPE_CHECKING:
|
35
|
+
from .cog import Cog
|
34
36
|
from disagreement.client import Client
|
35
37
|
from disagreement.models import Message, User
|
36
38
|
|
@@ -86,6 +88,9 @@ class Command:
|
|
86
88
|
await self.callback(ctx, *args, **kwargs)
|
87
89
|
|
88
90
|
|
91
|
+
PrefixCommand = Command # Alias for clarity in hybrid commands
|
92
|
+
|
93
|
+
|
89
94
|
class CommandContext:
|
90
95
|
"""
|
91
96
|
Represents the context in which a command is being invoked.
|
@@ -123,7 +128,7 @@ class CommandContext:
|
|
123
128
|
|
124
129
|
async def reply(
|
125
130
|
self,
|
126
|
-
content: str,
|
131
|
+
content: Optional[str] = None,
|
127
132
|
*,
|
128
133
|
mention_author: Optional[bool] = None,
|
129
134
|
**kwargs: Any,
|
@@ -235,6 +240,7 @@ class CommandHandler:
|
|
235
240
|
return self.commands.get(name.lower())
|
236
241
|
|
237
242
|
def add_cog(self, cog_to_add: "Cog") -> None:
|
243
|
+
from .cog import Cog
|
238
244
|
if not isinstance(cog_to_add, Cog):
|
239
245
|
raise TypeError("Argument must be a subclass of Cog.")
|
240
246
|
|
disagreement/ext/tasks.py
CHANGED
@@ -18,6 +18,8 @@ class Task:
|
|
18
18
|
delta: Optional[datetime.timedelta] = None,
|
19
19
|
time_of_day: Optional[datetime.time] = None,
|
20
20
|
on_error: Optional[Callable[[Exception], Awaitable[None]]] = None,
|
21
|
+
before_loop: Optional[Callable[[], Awaitable[None] | None]] = None,
|
22
|
+
after_loop: Optional[Callable[[], Awaitable[None] | None]] = None,
|
21
23
|
) -> None:
|
22
24
|
self._coro = coro
|
23
25
|
self._task: Optional[asyncio.Task[None]] = None
|
@@ -36,6 +38,8 @@ class Task:
|
|
36
38
|
self._seconds = float(interval_seconds)
|
37
39
|
self._time_of_day = time_of_day
|
38
40
|
self._on_error = on_error
|
41
|
+
self._before_loop = before_loop
|
42
|
+
self._after_loop = after_loop
|
39
43
|
|
40
44
|
def _seconds_until_time(self) -> float:
|
41
45
|
assert self._time_of_day is not None
|
@@ -47,6 +51,9 @@ class Task:
|
|
47
51
|
|
48
52
|
async def _run(self, *args: Any, **kwargs: Any) -> None:
|
49
53
|
try:
|
54
|
+
if self._before_loop is not None:
|
55
|
+
await _maybe_call_no_args(self._before_loop)
|
56
|
+
|
50
57
|
first = True
|
51
58
|
while True:
|
52
59
|
if self._time_of_day is not None:
|
@@ -65,6 +72,9 @@ class Task:
|
|
65
72
|
first = False
|
66
73
|
except asyncio.CancelledError:
|
67
74
|
pass
|
75
|
+
finally:
|
76
|
+
if self._after_loop is not None:
|
77
|
+
await _maybe_call_no_args(self._after_loop)
|
68
78
|
|
69
79
|
def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
|
70
80
|
if self._task is None or self._task.done():
|
@@ -89,6 +99,12 @@ async def _maybe_call(
|
|
89
99
|
await result
|
90
100
|
|
91
101
|
|
102
|
+
async def _maybe_call_no_args(func: Callable[[], Awaitable[None] | None]) -> None:
|
103
|
+
result = func()
|
104
|
+
if asyncio.iscoroutine(result):
|
105
|
+
await result
|
106
|
+
|
107
|
+
|
92
108
|
class _Loop:
|
93
109
|
def __init__(
|
94
110
|
self,
|
@@ -110,6 +126,8 @@ class _Loop:
|
|
110
126
|
self.on_error = on_error
|
111
127
|
self._task: Optional[Task] = None
|
112
128
|
self._owner: Any = None
|
129
|
+
self._before_loop: Optional[Callable[..., Awaitable[Any]]] = None
|
130
|
+
self._after_loop: Optional[Callable[..., Awaitable[Any]]] = None
|
113
131
|
|
114
132
|
def __get__(self, obj: Any, objtype: Any) -> "_BoundLoop":
|
115
133
|
return _BoundLoop(self, obj)
|
@@ -119,7 +137,33 @@ class _Loop:
|
|
119
137
|
return self.func(*args, **kwargs)
|
120
138
|
return self.func(self._owner, *args, **kwargs)
|
121
139
|
|
140
|
+
def before_loop(
|
141
|
+
self, func: Callable[..., Awaitable[Any]]
|
142
|
+
) -> Callable[..., Awaitable[Any]]:
|
143
|
+
self._before_loop = func
|
144
|
+
return func
|
145
|
+
|
146
|
+
def after_loop(
|
147
|
+
self, func: Callable[..., Awaitable[Any]]
|
148
|
+
) -> Callable[..., Awaitable[Any]]:
|
149
|
+
self._after_loop = func
|
150
|
+
return func
|
151
|
+
|
122
152
|
def start(self, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
|
153
|
+
def call_before() -> Awaitable[None] | None:
|
154
|
+
if self._before_loop is None:
|
155
|
+
return None
|
156
|
+
if self._owner is not None:
|
157
|
+
return self._before_loop(self._owner)
|
158
|
+
return self._before_loop()
|
159
|
+
|
160
|
+
def call_after() -> Awaitable[None] | None:
|
161
|
+
if self._after_loop is None:
|
162
|
+
return None
|
163
|
+
if self._owner is not None:
|
164
|
+
return self._after_loop(self._owner)
|
165
|
+
return self._after_loop()
|
166
|
+
|
123
167
|
self._task = Task(
|
124
168
|
self._coro,
|
125
169
|
seconds=self.seconds,
|
@@ -128,6 +172,8 @@ class _Loop:
|
|
128
172
|
delta=self.delta,
|
129
173
|
time_of_day=self.time_of_day,
|
130
174
|
on_error=self.on_error,
|
175
|
+
before_loop=call_before,
|
176
|
+
after_loop=call_after,
|
131
177
|
)
|
132
178
|
return self._task.start(*args, **kwargs)
|
133
179
|
|
disagreement/http.py
CHANGED
@@ -17,11 +17,13 @@ from .errors import (
|
|
17
17
|
DisagreementException,
|
18
18
|
)
|
19
19
|
from . import __version__ # For User-Agent
|
20
|
+
from .rate_limiter import RateLimiter
|
21
|
+
from .interactions import InteractionResponsePayload
|
20
22
|
|
21
23
|
if TYPE_CHECKING:
|
22
24
|
from .client import Client
|
23
25
|
from .models import Message, Webhook, File
|
24
|
-
from .interactions import ApplicationCommand,
|
26
|
+
from .interactions import ApplicationCommand, Snowflake
|
25
27
|
|
26
28
|
# Discord API constants
|
27
29
|
API_BASE_URL = "https://discord.com/api/v10" # Using API v10
|
@@ -44,8 +46,7 @@ class HTTPClient:
|
|
44
46
|
|
45
47
|
self.verbose = verbose
|
46
48
|
|
47
|
-
self.
|
48
|
-
self._global_rate_limit_lock.set() # Initially unlocked
|
49
|
+
self._rate_limiter = RateLimiter()
|
49
50
|
|
50
51
|
async def _ensure_session(self):
|
51
52
|
if self._session is None or self._session.closed:
|
@@ -87,10 +88,10 @@ class HTTPClient:
|
|
87
88
|
if self.verbose:
|
88
89
|
print(f"HTTP REQUEST: {method} {url} | payload={payload} params={params}")
|
89
90
|
|
90
|
-
|
91
|
-
await self._global_rate_limit_lock.wait()
|
91
|
+
route = f"{method.upper()}:{endpoint}"
|
92
92
|
|
93
93
|
for attempt in range(5): # Max 5 retries for rate limits
|
94
|
+
await self._rate_limiter.acquire(route)
|
94
95
|
assert self._session is not None, "ClientSession not initialized"
|
95
96
|
async with self._session.request(
|
96
97
|
method,
|
@@ -120,6 +121,8 @@ class HTTPClient:
|
|
120
121
|
if self.verbose:
|
121
122
|
print(f"HTTP RESPONSE: {response.status} {url} | {data}")
|
122
123
|
|
124
|
+
self._rate_limiter.release(route, response.headers)
|
125
|
+
|
123
126
|
if 200 <= response.status < 300:
|
124
127
|
if response.status == 204:
|
125
128
|
return None
|
@@ -142,12 +145,9 @@ class HTTPClient:
|
|
142
145
|
if data and isinstance(data, dict) and "message" in data:
|
143
146
|
error_message += f" Discord says: {data['message']}"
|
144
147
|
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
self._global_rate_limit_lock.set()
|
149
|
-
else:
|
150
|
-
await asyncio.sleep(retry_after)
|
148
|
+
await self._rate_limiter.handle_rate_limit(
|
149
|
+
route, retry_after, is_global
|
150
|
+
)
|
151
151
|
|
152
152
|
if attempt < 4: # Don't log on the last attempt before raising
|
153
153
|
print(
|
@@ -348,6 +348,16 @@ class HTTPClient:
|
|
348
348
|
f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}",
|
349
349
|
)
|
350
350
|
|
351
|
+
async def clear_reactions(
|
352
|
+
self, channel_id: "Snowflake", message_id: "Snowflake"
|
353
|
+
) -> None:
|
354
|
+
"""Removes all reactions from a message."""
|
355
|
+
|
356
|
+
await self.request(
|
357
|
+
"DELETE",
|
358
|
+
f"/channels/{channel_id}/messages/{message_id}/reactions",
|
359
|
+
)
|
360
|
+
|
351
361
|
async def bulk_delete_messages(
|
352
362
|
self, channel_id: "Snowflake", messages: List["Snowflake"]
|
353
363
|
) -> List["Snowflake"]:
|
@@ -411,6 +421,92 @@ class HTTPClient:
|
|
411
421
|
|
412
422
|
await self.request("DELETE", f"/webhooks/{webhook_id}")
|
413
423
|
|
424
|
+
async def execute_webhook(
|
425
|
+
self,
|
426
|
+
webhook_id: "Snowflake",
|
427
|
+
token: str,
|
428
|
+
*,
|
429
|
+
content: Optional[str] = None,
|
430
|
+
tts: bool = False,
|
431
|
+
embeds: Optional[List[Dict[str, Any]]] = None,
|
432
|
+
components: Optional[List[Dict[str, Any]]] = None,
|
433
|
+
allowed_mentions: Optional[dict] = None,
|
434
|
+
attachments: Optional[List[Any]] = None,
|
435
|
+
files: Optional[List[Any]] = None,
|
436
|
+
flags: Optional[int] = None,
|
437
|
+
username: Optional[str] = None,
|
438
|
+
avatar_url: Optional[str] = None,
|
439
|
+
) -> Dict[str, Any]:
|
440
|
+
"""Executes a webhook and returns the created message."""
|
441
|
+
|
442
|
+
payload: Dict[str, Any] = {}
|
443
|
+
if content is not None:
|
444
|
+
payload["content"] = content
|
445
|
+
if tts:
|
446
|
+
payload["tts"] = True
|
447
|
+
if embeds:
|
448
|
+
payload["embeds"] = embeds
|
449
|
+
if components:
|
450
|
+
payload["components"] = components
|
451
|
+
if allowed_mentions:
|
452
|
+
payload["allowed_mentions"] = allowed_mentions
|
453
|
+
if username:
|
454
|
+
payload["username"] = username
|
455
|
+
if avatar_url:
|
456
|
+
payload["avatar_url"] = avatar_url
|
457
|
+
|
458
|
+
all_files: List["File"] = []
|
459
|
+
if attachments is not None:
|
460
|
+
payload["attachments"] = []
|
461
|
+
for a in attachments:
|
462
|
+
if hasattr(a, "data") and hasattr(a, "filename"):
|
463
|
+
idx = len(all_files)
|
464
|
+
all_files.append(a)
|
465
|
+
payload["attachments"].append({"id": idx, "filename": a.filename})
|
466
|
+
else:
|
467
|
+
payload["attachments"].append(
|
468
|
+
a.to_dict() if hasattr(a, "to_dict") else a
|
469
|
+
)
|
470
|
+
if files is not None:
|
471
|
+
for f in files:
|
472
|
+
if hasattr(f, "data") and hasattr(f, "filename"):
|
473
|
+
idx = len(all_files)
|
474
|
+
all_files.append(f)
|
475
|
+
if "attachments" not in payload:
|
476
|
+
payload["attachments"] = []
|
477
|
+
payload["attachments"].append({"id": idx, "filename": f.filename})
|
478
|
+
else:
|
479
|
+
raise TypeError("files must be File objects")
|
480
|
+
if flags:
|
481
|
+
payload["flags"] = flags
|
482
|
+
|
483
|
+
if all_files:
|
484
|
+
form = aiohttp.FormData()
|
485
|
+
form.add_field(
|
486
|
+
"payload_json", json.dumps(payload), content_type="application/json"
|
487
|
+
)
|
488
|
+
for idx, f in enumerate(all_files):
|
489
|
+
form.add_field(
|
490
|
+
f"files[{idx}]",
|
491
|
+
f.data,
|
492
|
+
filename=f.filename,
|
493
|
+
content_type="application/octet-stream",
|
494
|
+
)
|
495
|
+
return await self.request(
|
496
|
+
"POST",
|
497
|
+
f"/webhooks/{webhook_id}/{token}",
|
498
|
+
payload=form,
|
499
|
+
is_json=False,
|
500
|
+
use_auth_header=False,
|
501
|
+
)
|
502
|
+
|
503
|
+
return await self.request(
|
504
|
+
"POST",
|
505
|
+
f"/webhooks/{webhook_id}/{token}",
|
506
|
+
payload=payload,
|
507
|
+
use_auth_header=False,
|
508
|
+
)
|
509
|
+
|
414
510
|
async def get_user(self, user_id: "Snowflake") -> Dict[str, Any]:
|
415
511
|
"""Fetches a user object for a given user ID."""
|
416
512
|
return await self.request("GET", f"/users/{user_id}")
|
@@ -657,7 +753,7 @@ class HTTPClient:
|
|
657
753
|
self,
|
658
754
|
interaction_id: "Snowflake",
|
659
755
|
interaction_token: str,
|
660
|
-
payload: "InteractionResponsePayload",
|
756
|
+
payload: Union["InteractionResponsePayload", Dict[str, Any]],
|
661
757
|
*,
|
662
758
|
ephemeral: bool = False,
|
663
759
|
) -> None:
|
@@ -670,10 +766,16 @@ class HTTPClient:
|
|
670
766
|
"""
|
671
767
|
# Interaction responses do not use the bot token in the Authorization header.
|
672
768
|
# They are authenticated by the interaction_token in the URL.
|
769
|
+
payload_data: Dict[str, Any]
|
770
|
+
if isinstance(payload, InteractionResponsePayload):
|
771
|
+
payload_data = payload.to_dict()
|
772
|
+
else:
|
773
|
+
payload_data = payload
|
774
|
+
|
673
775
|
await self.request(
|
674
776
|
"POST",
|
675
777
|
f"/interactions/{interaction_id}/{interaction_token}/callback",
|
676
|
-
payload=
|
778
|
+
payload=payload_data,
|
677
779
|
use_auth_header=False,
|
678
780
|
)
|
679
781
|
|
disagreement/interactions.py
CHANGED
@@ -395,17 +395,14 @@ class Interaction:
|
|
395
395
|
|
396
396
|
async def respond_modal(self, modal: "Modal") -> None:
|
397
397
|
"""|coro| Send a modal in response to this interaction."""
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
"type": InteractionCallbackType.MODAL.value,
|
403
|
-
"data": modal.to_dict(),
|
404
|
-
}
|
398
|
+
payload = InteractionResponsePayload(
|
399
|
+
type=InteractionCallbackType.MODAL,
|
400
|
+
data=modal.to_dict(),
|
401
|
+
)
|
405
402
|
await self._client._http.create_interaction_response(
|
406
403
|
interaction_id=self.id,
|
407
404
|
interaction_token=self.token,
|
408
|
-
payload=
|
405
|
+
payload=payload,
|
409
406
|
)
|
410
407
|
|
411
408
|
async def edit(
|
@@ -489,7 +486,7 @@ class InteractionResponse:
|
|
489
486
|
"""Sends a modal response."""
|
490
487
|
payload = InteractionResponsePayload(
|
491
488
|
type=InteractionCallbackType.MODAL,
|
492
|
-
data=
|
489
|
+
data=modal.to_dict(),
|
493
490
|
)
|
494
491
|
await self._interaction._client._http.create_interaction_response(
|
495
492
|
self._interaction.id,
|
@@ -510,7 +507,7 @@ class InteractionCallbackData:
|
|
510
507
|
)
|
511
508
|
self.allowed_mentions: Optional[AllowedMentions] = (
|
512
509
|
AllowedMentions(data["allowed_mentions"])
|
513
|
-
if
|
510
|
+
if "allowed_mentions" in data
|
514
511
|
else None
|
515
512
|
)
|
516
513
|
self.flags: Optional[int] = data.get("flags") # MessageFlags enum could be used
|
@@ -557,16 +554,22 @@ class InteractionResponsePayload:
|
|
557
554
|
def __init__(
|
558
555
|
self,
|
559
556
|
type: InteractionCallbackType,
|
560
|
-
data: Optional[InteractionCallbackData] = None,
|
557
|
+
data: Optional[Union[InteractionCallbackData, Dict[str, Any]]] = None,
|
561
558
|
):
|
562
|
-
self.type
|
563
|
-
self.data
|
559
|
+
self.type = type
|
560
|
+
self.data = data
|
564
561
|
|
565
562
|
def to_dict(self) -> Dict[str, Any]:
|
566
563
|
payload: Dict[str, Any] = {"type": self.type.value}
|
567
564
|
if self.data:
|
568
|
-
|
565
|
+
if isinstance(self.data, dict):
|
566
|
+
payload["data"] = self.data
|
567
|
+
else:
|
568
|
+
payload["data"] = self.data.to_dict()
|
569
569
|
return payload
|
570
570
|
|
571
571
|
def __repr__(self) -> str:
|
572
572
|
return f"<InteractionResponsePayload type={self.type!r}>"
|
573
|
+
|
574
|
+
def __getitem__(self, key: str) -> Any:
|
575
|
+
return self.to_dict()[key]
|
disagreement/models.py
CHANGED
@@ -8,7 +8,7 @@ import json
|
|
8
8
|
import asyncio
|
9
9
|
import aiohttp # pylint: disable=import-error
|
10
10
|
import asyncio
|
11
|
-
from typing import
|
11
|
+
from typing import Any, AsyncIterator, Dict, List, Optional, TYPE_CHECKING, Union
|
12
12
|
|
13
13
|
from .errors import DisagreementException, HTTPException
|
14
14
|
from .enums import ( # These enums will need to be defined in disagreement/enums.py
|
@@ -25,6 +25,7 @@ from .enums import ( # These enums will need to be defined in disagreement/enum
|
|
25
25
|
# SelectMenuType will be part of ComponentType or a new enum if needed
|
26
26
|
)
|
27
27
|
from .permissions import Permissions
|
28
|
+
from .color import Color
|
28
29
|
|
29
30
|
|
30
31
|
if TYPE_CHECKING:
|
@@ -201,6 +202,21 @@ class Message:
|
|
201
202
|
view=view,
|
202
203
|
)
|
203
204
|
|
205
|
+
async def add_reaction(self, emoji: str) -> None:
|
206
|
+
"""|coro| Add a reaction to this message."""
|
207
|
+
|
208
|
+
await self._client.add_reaction(self.channel_id, self.id, emoji)
|
209
|
+
|
210
|
+
async def remove_reaction(self, emoji: str) -> None:
|
211
|
+
"""|coro| Remove the bot's reaction from this message."""
|
212
|
+
|
213
|
+
await self._client.remove_reaction(self.channel_id, self.id, emoji)
|
214
|
+
|
215
|
+
async def clear_reactions(self) -> None:
|
216
|
+
"""|coro| Remove all reactions from this message."""
|
217
|
+
|
218
|
+
await self._client.clear_reactions(self.channel_id, self.id)
|
219
|
+
|
204
220
|
async def delete(self, delay: Optional[float] = None) -> None:
|
205
221
|
"""|coro|
|
206
222
|
|
@@ -312,7 +328,7 @@ class Embed:
|
|
312
328
|
self.description: Optional[str] = data.get("description")
|
313
329
|
self.url: Optional[str] = data.get("url")
|
314
330
|
self.timestamp: Optional[str] = data.get("timestamp") # ISO8601 timestamp
|
315
|
-
self.color
|
331
|
+
self.color = Color.parse(data.get("color"))
|
316
332
|
|
317
333
|
self.footer: Optional[EmbedFooter] = (
|
318
334
|
EmbedFooter(data["footer"]) if data.get("footer") else None
|
@@ -342,7 +358,7 @@ class Embed:
|
|
342
358
|
if self.timestamp:
|
343
359
|
payload["timestamp"] = self.timestamp
|
344
360
|
if self.color is not None:
|
345
|
-
payload["color"] = self.color
|
361
|
+
payload["color"] = self.color.value
|
346
362
|
if self.footer:
|
347
363
|
payload["footer"] = self.footer.to_dict()
|
348
364
|
if self.image:
|
@@ -528,6 +544,12 @@ class Member(User): # Member inherits from User
|
|
528
544
|
def __repr__(self) -> str:
|
529
545
|
return f"<Member id='{self.id}' username='{self.username}' nick='{self.nick}'>"
|
530
546
|
|
547
|
+
@property
|
548
|
+
def display_name(self) -> str:
|
549
|
+
"""Return the nickname if set, otherwise the username."""
|
550
|
+
|
551
|
+
return self.nick or self.username
|
552
|
+
|
531
553
|
async def kick(self, *, reason: Optional[str] = None) -> None:
|
532
554
|
if not self.guild_id or not self._client:
|
533
555
|
raise DisagreementException("Member.kick requires guild_id and client")
|
@@ -1065,6 +1087,20 @@ class TextChannel(Channel):
|
|
1065
1087
|
)
|
1066
1088
|
self.last_pin_timestamp: Optional[str] = data.get("last_pin_timestamp")
|
1067
1089
|
|
1090
|
+
|
1091
|
+
def history(
|
1092
|
+
self,
|
1093
|
+
*,
|
1094
|
+
limit: Optional[int] = None,
|
1095
|
+
before: Optional[str] = None,
|
1096
|
+
after: Optional[str] = None,
|
1097
|
+
) -> AsyncIterator["Message"]:
|
1098
|
+
"""Return an async iterator over this channel's messages."""
|
1099
|
+
|
1100
|
+
from .utils import message_pager
|
1101
|
+
|
1102
|
+
return message_pager(self, limit=limit, before=before, after=after)
|
1103
|
+
|
1068
1104
|
async def send(
|
1069
1105
|
self,
|
1070
1106
|
content: Optional[str] = None,
|
@@ -1107,6 +1143,7 @@ class TextChannel(Channel):
|
|
1107
1143
|
self._client._messages.pop(mid, None)
|
1108
1144
|
return ids
|
1109
1145
|
|
1146
|
+
|
1110
1147
|
def __repr__(self) -> str:
|
1111
1148
|
return f"<TextChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
|
1112
1149
|
|
@@ -1224,6 +1261,36 @@ class DMChannel(Channel):
|
|
1224
1261
|
components=components,
|
1225
1262
|
)
|
1226
1263
|
|
1264
|
+
async def history(
|
1265
|
+
self,
|
1266
|
+
*,
|
1267
|
+
limit: Optional[int] = 100,
|
1268
|
+
before: "Snowflake | None" = None,
|
1269
|
+
):
|
1270
|
+
"""An async iterator over messages in this DM."""
|
1271
|
+
|
1272
|
+
params: Dict[str, Union[int, str]] = {}
|
1273
|
+
if before is not None:
|
1274
|
+
params["before"] = before
|
1275
|
+
|
1276
|
+
fetched = 0
|
1277
|
+
while True:
|
1278
|
+
to_fetch = 100 if limit is None else min(100, limit - fetched)
|
1279
|
+
if to_fetch <= 0:
|
1280
|
+
break
|
1281
|
+
params["limit"] = to_fetch
|
1282
|
+
messages = await self._client._http.request(
|
1283
|
+
"GET", f"/channels/{self.id}/messages", params=params.copy()
|
1284
|
+
)
|
1285
|
+
if not messages:
|
1286
|
+
break
|
1287
|
+
params["before"] = messages[-1]["id"]
|
1288
|
+
for msg in messages:
|
1289
|
+
yield Message(msg, self._client)
|
1290
|
+
fetched += 1
|
1291
|
+
if limit is not None and fetched >= limit:
|
1292
|
+
return
|
1293
|
+
|
1227
1294
|
def __repr__(self) -> str:
|
1228
1295
|
recipient_repr = self.recipient.username if self.recipient else "Unknown"
|
1229
1296
|
return f"<DMChannel id='{self.id}' recipient='{recipient_repr}'>"
|
@@ -1317,6 +1384,57 @@ class Webhook:
|
|
1317
1384
|
|
1318
1385
|
return cls({"id": webhook_id, "token": token, "url": url})
|
1319
1386
|
|
1387
|
+
async def send(
|
1388
|
+
self,
|
1389
|
+
content: Optional[str] = None,
|
1390
|
+
*,
|
1391
|
+
username: Optional[str] = None,
|
1392
|
+
avatar_url: Optional[str] = None,
|
1393
|
+
tts: bool = False,
|
1394
|
+
embed: Optional["Embed"] = None,
|
1395
|
+
embeds: Optional[List["Embed"]] = None,
|
1396
|
+
components: Optional[List["ActionRow"]] = None,
|
1397
|
+
allowed_mentions: Optional[Dict[str, Any]] = None,
|
1398
|
+
attachments: Optional[List[Any]] = None,
|
1399
|
+
files: Optional[List[Any]] = None,
|
1400
|
+
flags: Optional[int] = None,
|
1401
|
+
) -> "Message":
|
1402
|
+
"""Send a message using this webhook."""
|
1403
|
+
|
1404
|
+
if not self._client:
|
1405
|
+
raise DisagreementException("Webhook is not bound to a Client")
|
1406
|
+
assert self.token is not None, "Webhook token missing"
|
1407
|
+
|
1408
|
+
if embed and embeds:
|
1409
|
+
raise ValueError("Cannot provide both embed and embeds.")
|
1410
|
+
|
1411
|
+
final_embeds_payload: Optional[List[Dict[str, Any]]] = None
|
1412
|
+
if embed:
|
1413
|
+
final_embeds_payload = [embed.to_dict()]
|
1414
|
+
elif embeds:
|
1415
|
+
final_embeds_payload = [e.to_dict() for e in embeds]
|
1416
|
+
|
1417
|
+
components_payload: Optional[List[Dict[str, Any]]] = None
|
1418
|
+
if components:
|
1419
|
+
components_payload = [c.to_dict() for c in components]
|
1420
|
+
|
1421
|
+
message_data = await self._client._http.execute_webhook(
|
1422
|
+
self.id,
|
1423
|
+
self.token,
|
1424
|
+
content=content,
|
1425
|
+
tts=tts,
|
1426
|
+
embeds=final_embeds_payload,
|
1427
|
+
components=components_payload,
|
1428
|
+
allowed_mentions=allowed_mentions,
|
1429
|
+
attachments=attachments,
|
1430
|
+
files=files,
|
1431
|
+
flags=flags,
|
1432
|
+
username=username,
|
1433
|
+
avatar_url=avatar_url,
|
1434
|
+
)
|
1435
|
+
|
1436
|
+
return self._client.parse_message(message_data)
|
1437
|
+
|
1320
1438
|
|
1321
1439
|
# --- Message Components ---
|
1322
1440
|
|
@@ -1708,13 +1826,13 @@ class Container(Component):
|
|
1708
1826
|
def __init__(
|
1709
1827
|
self,
|
1710
1828
|
components: List[Component],
|
1711
|
-
accent_color:
|
1829
|
+
accent_color: Color | int | str | None = None,
|
1712
1830
|
spoiler: bool = False,
|
1713
1831
|
id: Optional[int] = None,
|
1714
1832
|
):
|
1715
1833
|
super().__init__(ComponentType.CONTAINER)
|
1716
1834
|
self.components = components
|
1717
|
-
self.accent_color = accent_color
|
1835
|
+
self.accent_color = Color.parse(accent_color)
|
1718
1836
|
self.spoiler = spoiler
|
1719
1837
|
self.id = id
|
1720
1838
|
|
@@ -1722,7 +1840,7 @@ class Container(Component):
|
|
1722
1840
|
payload = super().to_dict()
|
1723
1841
|
payload["components"] = [c.to_dict() for c in self.components]
|
1724
1842
|
if self.accent_color:
|
1725
|
-
payload["accent_color"] = self.accent_color
|
1843
|
+
payload["accent_color"] = self.accent_color.value
|
1726
1844
|
if self.spoiler:
|
1727
1845
|
payload["spoiler"] = self.spoiler
|
1728
1846
|
if self.id is not None:
|
disagreement/ui/modal.py
CHANGED
disagreement/utils.py
CHANGED
@@ -3,8 +3,71 @@
|
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
5
|
from datetime import datetime, timezone
|
6
|
+
from typing import Any, AsyncIterator, Dict, Optional, TYPE_CHECKING
|
7
|
+
|
8
|
+
if TYPE_CHECKING: # pragma: no cover - for type hinting only
|
9
|
+
from .models import Message, TextChannel
|
6
10
|
|
7
11
|
|
8
12
|
def utcnow() -> datetime:
|
9
13
|
"""Return the current timezone-aware UTC time."""
|
10
14
|
return datetime.now(timezone.utc)
|
15
|
+
|
16
|
+
|
17
|
+
async def message_pager(
|
18
|
+
channel: "TextChannel",
|
19
|
+
*,
|
20
|
+
limit: Optional[int] = None,
|
21
|
+
before: Optional[str] = None,
|
22
|
+
after: Optional[str] = None,
|
23
|
+
) -> AsyncIterator["Message"]:
|
24
|
+
"""Asynchronously paginate a channel's messages.
|
25
|
+
|
26
|
+
Parameters
|
27
|
+
----------
|
28
|
+
channel:
|
29
|
+
The :class:`TextChannel` to fetch messages from.
|
30
|
+
limit:
|
31
|
+
The maximum number of messages to yield. ``None`` fetches until no
|
32
|
+
more messages are returned.
|
33
|
+
before:
|
34
|
+
Fetch messages with IDs less than this snowflake.
|
35
|
+
after:
|
36
|
+
Fetch messages with IDs greater than this snowflake.
|
37
|
+
|
38
|
+
Yields
|
39
|
+
------
|
40
|
+
Message
|
41
|
+
Messages in the channel, oldest first.
|
42
|
+
"""
|
43
|
+
|
44
|
+
remaining = limit
|
45
|
+
last_id = before
|
46
|
+
while remaining is None or remaining > 0:
|
47
|
+
fetch_limit = 100
|
48
|
+
if remaining is not None:
|
49
|
+
fetch_limit = min(fetch_limit, remaining)
|
50
|
+
|
51
|
+
params: Dict[str, Any] = {"limit": fetch_limit}
|
52
|
+
if last_id is not None:
|
53
|
+
params["before"] = last_id
|
54
|
+
if after is not None:
|
55
|
+
params["after"] = after
|
56
|
+
|
57
|
+
data = await channel._client._http.request( # type: ignore[attr-defined]
|
58
|
+
"GET",
|
59
|
+
f"/channels/{channel.id}/messages",
|
60
|
+
params=params,
|
61
|
+
)
|
62
|
+
|
63
|
+
if not data:
|
64
|
+
break
|
65
|
+
|
66
|
+
for raw in data:
|
67
|
+
msg = channel._client.parse_message(raw) # type: ignore[attr-defined]
|
68
|
+
yield msg
|
69
|
+
last_id = msg.id
|
70
|
+
if remaining is not None:
|
71
|
+
remaining -= 1
|
72
|
+
if remaining == 0:
|
73
|
+
return
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: disagreement
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.0rc2
|
4
4
|
Summary: A Python library for the Discord API.
|
5
5
|
Author-email: Slipstream <me@slipstreamm.dev>
|
6
6
|
License: BSD 3-Clause
|
@@ -12,22 +12,23 @@ Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: License :: OSI Approved :: BSD License
|
13
13
|
Classifier: Operating System :: OS Independent
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
15
16
|
Classifier: Programming Language :: Python :: 3.11
|
16
17
|
Classifier: Programming Language :: Python :: 3.12
|
17
18
|
Classifier: Programming Language :: Python :: 3.13
|
18
19
|
Classifier: Topic :: Software Development :: Libraries
|
19
20
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
20
21
|
Classifier: Topic :: Internet
|
21
|
-
Requires-Python: >=3.
|
22
|
+
Requires-Python: >=3.10
|
22
23
|
Description-Content-Type: text/markdown
|
23
24
|
License-File: LICENSE
|
24
25
|
Requires-Dist: aiohttp<4.0.0,>=3.9.0
|
25
26
|
Provides-Extra: test
|
26
27
|
Requires-Dist: pytest>=8.0.0; extra == "test"
|
27
28
|
Requires-Dist: pytest-asyncio>=1.0.0; extra == "test"
|
28
|
-
Requires-Dist: hypothesis>=6.
|
29
|
+
Requires-Dist: hypothesis>=6.132.0; extra == "test"
|
29
30
|
Provides-Extra: dev
|
30
|
-
Requires-Dist: dotenv>=0.0
|
31
|
+
Requires-Dist: python-dotenv>=1.0.0; extra == "dev"
|
31
32
|
Dynamic: license-file
|
32
33
|
|
33
34
|
# Disagreement
|
@@ -53,7 +54,7 @@ pip install disagreement
|
|
53
54
|
pip install -e .
|
54
55
|
```
|
55
56
|
|
56
|
-
Requires Python 3.
|
57
|
+
Requires Python 3.10 or newer.
|
57
58
|
|
58
59
|
## Basic Usage
|
59
60
|
|
@@ -1,40 +1,41 @@
|
|
1
|
-
disagreement/__init__.py,sha256=
|
1
|
+
disagreement/__init__.py,sha256=4T6_19N6SjwgFXFONo6GjB3VbgODPZUc5soxJoVS_zY,1084
|
2
2
|
disagreement/audio.py,sha256=P6inobI8CNhNVkaRKU58RMYtLq1RrSREioF0Mui5VlA,3351
|
3
3
|
disagreement/cache.py,sha256=juabGFl4naQih5OUIVN2aN-vAfw2ZC2cI38s4nGEn8U,1525
|
4
|
-
disagreement/client.py,sha256=
|
5
|
-
disagreement/color.py,sha256=
|
4
|
+
disagreement/client.py,sha256=7or564FS7fXALDyRvNrt-H32mtV1VLysuIs3CtQ9L3s,53182
|
5
|
+
disagreement/color.py,sha256=g-1ynMGCUbY0f6jJXzMLS1aJFoZg91bdMetFkZgaCC0,2387
|
6
6
|
disagreement/components.py,sha256=tEYJ2RHVpIFtZuPPxZ0v8ssUw_x7ybhYBzHNsRiXXvU,5250
|
7
|
-
disagreement/enums.py,sha256=
|
7
|
+
disagreement/enums.py,sha256=CP03oF28maaPUVckOfE_tnVIm2ZOc5WL8mWlocmjeOQ,9785
|
8
8
|
disagreement/error_handler.py,sha256=c2lb6aTMnhTtITQuR6axZUtEaasYKUgmdSxAHEkeq50,1028
|
9
9
|
disagreement/errors.py,sha256=XiYVPy8uFUGVi_EIf81yK7QbC7KyN4JHplSJSWw2RRk,3185
|
10
10
|
disagreement/event_dispatcher.py,sha256=mp4LVhIj0SW1P2NruqbYpZoYH33X5rXvkAl3-RK40kE,11460
|
11
11
|
disagreement/gateway.py,sha256=mhFtBm_YPtbleQJklv3ph3DXE4LZxC1BhtNkd7Y-akQ,23113
|
12
|
-
disagreement/http.py,sha256=
|
12
|
+
disagreement/http.py,sha256=4FnVMIRjrNSpQ9IUEeWQGP6jYAhv3YTTANqFDNv0SdY,31046
|
13
13
|
disagreement/hybrid_context.py,sha256=VYCmcreTZdPBU9v-Cy48W38vgWO2t8nM2ulC6_z4HjU,1095
|
14
14
|
disagreement/i18n.py,sha256=1L4rcFuKP0XjHk9dVwbNh4BkLk2ZlxxZ_-tecGWa9S0,718
|
15
|
-
disagreement/interactions.py,sha256=
|
15
|
+
disagreement/interactions.py,sha256=aUZwwEOLsEds15i6G-rxmSSDCDmaxz_cfoTYS4tv6Ao,21735
|
16
16
|
disagreement/logging_config.py,sha256=4q6baQPE6X_0lfaBTFMU1uqc03x5SbJqo2hsApdDFac,686
|
17
|
-
disagreement/models.py,sha256=
|
17
|
+
disagreement/models.py,sha256=JeHKJWrc7mN70oFNgd7Iic9_SgHfDlgz7aTRgMA-5PA,75070
|
18
18
|
disagreement/oauth.py,sha256=TfDdCwg1J7osM9wDi61dtNBA5BrQk5DeQrrHsYycH34,2810
|
19
19
|
disagreement/permissions.py,sha256=7g5cIlg-evHXOL0-pmtT5EwqcB-stXot1HZSLz724sE,3008
|
20
20
|
disagreement/rate_limiter.py,sha256=ubwR_UTPs2MotipBdtqpgwQKx0IHt2I3cdfFcXTFv7g,2521
|
21
21
|
disagreement/shard_manager.py,sha256=e9F8tx_4IEOlTX3-S3t51lfJToc6Ue3RVBzoNAiVKxs,2161
|
22
22
|
disagreement/typing.py,sha256=_1oFWfZ4HyH5Q3bnF7CO24s79z-3_B5Qb69kWiwLhhU,1242
|
23
|
-
disagreement/utils.py,sha256=
|
23
|
+
disagreement/utils.py,sha256=mz7foTCOAmUv9n8EcdZeiFarwqB14xHOG8o0p8tFuKA,2014
|
24
24
|
disagreement/voice_client.py,sha256=i_67gJ-SQWi9YH-pgtFM8N0lCYznyuQImyL-mf2O7KQ,5384
|
25
25
|
disagreement/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
26
26
|
disagreement/ext/loader.py,sha256=9_uULvNAa-a6UiaeQhWglwgIrHEPKbf9bnWtSL1KV5Q,1408
|
27
|
-
disagreement/ext/tasks.py,sha256=
|
28
|
-
disagreement/ext/app_commands/__init__.py,sha256=
|
29
|
-
disagreement/ext/app_commands/commands.py,sha256=
|
27
|
+
disagreement/ext/tasks.py,sha256=b14KI-btikbrjPlD76md3Ggt6znrxPqr7TDarU4PYBg,7269
|
28
|
+
disagreement/ext/app_commands/__init__.py,sha256=mnQLIuGP9SzqGMPEn5YgOh2eIU7lcYoDXP06vtXZfTA,1014
|
29
|
+
disagreement/ext/app_commands/commands.py,sha256=0O5fJQg2esTQzx2FyEpM2ZrrLckNmv8fs3TIPnr1Q_s,19020
|
30
30
|
disagreement/ext/app_commands/context.py,sha256=Xcm4Ka5K5uTQGviixF5LeCDdOdF9YQS5F7lZi2m--8s,20831
|
31
31
|
disagreement/ext/app_commands/converters.py,sha256=J1VEmo-7H9K7kGPJodu5FX4RmFFI1BuzhlQAEs2MsD4,21036
|
32
|
-
disagreement/ext/app_commands/decorators.py,sha256=
|
32
|
+
disagreement/ext/app_commands/decorators.py,sha256=dKiD4ZEsafRoPvfgn9zuQ9vvXXo2qYTMquHvyUM1604,23251
|
33
33
|
disagreement/ext/app_commands/handler.py,sha256=XO9yLgcV7aIxzhTMgFcQ1Tbr4GRZRfDBzkIAkiu6mw8,26045
|
34
|
+
disagreement/ext/app_commands/hybrid.py,sha256=yRDnlnOqgo79X669WhBHQ6LJCCuFDXKUbmlC_u3aXR0,3329
|
34
35
|
disagreement/ext/commands/__init__.py,sha256=HxZWVfc4qvP_bCRbKTVZoMqXFq19Gj4mQvRumvQiApQ,1130
|
35
36
|
disagreement/ext/commands/cog.py,sha256=U57yMrUpqj3_-W1-koyfGgH43MZG_JzJOl46kTur7iA,6636
|
36
37
|
disagreement/ext/commands/converters.py,sha256=mh8xJr1FIiah6bdYy0KsdccfYcPii2Yc_IdhzCTw5uE,5864
|
37
|
-
disagreement/ext/commands/core.py,sha256=
|
38
|
+
disagreement/ext/commands/core.py,sha256=vwsj3GR9wrEy0y-1HJv16Hg9-9xnm61tvT9b14QlYEI,19296
|
38
39
|
disagreement/ext/commands/decorators.py,sha256=Ox_D9KCFtMa-RiljFjOcsPb3stmDStRKeLw1DVeOdAw,6608
|
39
40
|
disagreement/ext/commands/errors.py,sha256=cG5sPA-osUq2gts5scrl5yT-BHEYVHLTb4TULjAmbaY,2065
|
40
41
|
disagreement/ext/commands/help.py,sha256=yw0ydupOsPwmnhsIIoxa93xjj9MAcBcGfD8BXa7V8G8,1456
|
@@ -42,11 +43,11 @@ disagreement/ext/commands/view.py,sha256=3Wo4gGJX9fb65qw8yHFwMjnAeJvMJAx19rZNHz-
|
|
42
43
|
disagreement/ui/__init__.py,sha256=PLA6eHiq9cu7JDOKS-7MKtaFhlqswjbI4AEUlpnbgO0,307
|
43
44
|
disagreement/ui/button.py,sha256=GHbY3-yMrvv6u674-qYONocuC1e2a0flEWjPKwJXKDo,3163
|
44
45
|
disagreement/ui/item.py,sha256=bm-EmQEZpe8Kt8JrRw-o0uQdccgjErORcFsBqaXOcV8,1112
|
45
|
-
disagreement/ui/modal.py,sha256=
|
46
|
+
disagreement/ui/modal.py,sha256=w0ZEVslXzx2-RWUP4jdVB54zDuT81jpueVWZ70byFnI,4137
|
46
47
|
disagreement/ui/select.py,sha256=XjWRlWkA09QZaDDLn-wDDOWIuj0Mb4VCWJEOAaExZXw,3018
|
47
48
|
disagreement/ui/view.py,sha256=QhWoYt39QKXwl1X6Mkm5gNNEqd8bt7O505lSpiG0L04,5567
|
48
|
-
disagreement-0.1.
|
49
|
-
disagreement-0.1.
|
50
|
-
disagreement-0.1.
|
51
|
-
disagreement-0.1.
|
52
|
-
disagreement-0.1.
|
49
|
+
disagreement-0.1.0rc2.dist-info/licenses/LICENSE,sha256=zfmtgJhVFHnqT7a8LAQFthXu5bP7EEHmEL99trV66Ew,1474
|
50
|
+
disagreement-0.1.0rc2.dist-info/METADATA,sha256=qMql6NO40Fgv8w-yvoFgBq7V8glJbRJvN3-pyeDnQOY,4889
|
51
|
+
disagreement-0.1.0rc2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
52
|
+
disagreement-0.1.0rc2.dist-info/top_level.txt,sha256=t7FY_3iaYhdB6X6y9VybJ2j7UZbVeRUe9wRgH8d5Gtk,13
|
53
|
+
disagreement-0.1.0rc2.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|