disagreement 0.0.2__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 +8 -3
- disagreement/audio.py +116 -0
- disagreement/client.py +217 -8
- disagreement/color.py +78 -0
- disagreement/components.py +2 -2
- disagreement/enums.py +5 -0
- disagreement/errors.py +13 -8
- disagreement/event_dispatcher.py +102 -45
- 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/__init__.py +9 -1
- disagreement/ext/commands/core.py +15 -2
- disagreement/ext/commands/decorators.py +72 -30
- disagreement/ext/loader.py +12 -1
- disagreement/ext/tasks.py +147 -8
- disagreement/gateway.py +56 -13
- disagreement/http.py +219 -16
- disagreement/interactions.py +17 -14
- disagreement/models.py +432 -7
- disagreement/shard_manager.py +2 -0
- disagreement/ui/modal.py +1 -1
- disagreement/utils.py +73 -0
- disagreement/voice_client.py +42 -0
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc2.dist-info}/METADATA +14 -6
- disagreement-0.1.0rc2.dist-info/RECORD +53 -0
- disagreement-0.0.2.dist-info/RECORD +0 -49
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc2.dist-info}/WHEEL +0 -0
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc2.dist-info}/licenses/LICENSE +0 -0
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc2.dist-info}/top_level.txt +0 -0
disagreement/__init__.py
CHANGED
@@ -14,18 +14,23 @@ __title__ = "disagreement"
|
|
14
14
|
__author__ = "Slipstream"
|
15
15
|
__license__ = "BSD 3-Clause License"
|
16
16
|
__copyright__ = "Copyright 2025 Slipstream"
|
17
|
-
__version__ = "0.
|
17
|
+
__version__ = "0.1.0rc2"
|
18
18
|
|
19
|
-
from .client import Client
|
20
|
-
from .models import Message, User
|
19
|
+
from .client import Client, AutoShardedClient
|
20
|
+
from .models import Message, User, Reaction
|
21
21
|
from .voice_client import VoiceClient
|
22
|
+
from .audio import AudioSource, FFmpegAudioSource
|
22
23
|
from .typing import Typing
|
23
24
|
from .errors import (
|
24
25
|
DisagreementException,
|
25
26
|
HTTPException,
|
26
27
|
GatewayException,
|
27
28
|
AuthenticationError,
|
29
|
+
Forbidden,
|
30
|
+
NotFound,
|
28
31
|
)
|
32
|
+
from .color import Color
|
33
|
+
from .utils import utcnow, message_pager
|
29
34
|
from .enums import GatewayIntent, GatewayOpcode # Export enums
|
30
35
|
from .error_handler import setup_global_error_handler
|
31
36
|
from .hybrid_context import HybridContext
|
disagreement/audio.py
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
"""Audio source abstractions for the voice client."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import asyncio
|
6
|
+
import contextlib
|
7
|
+
import io
|
8
|
+
from typing import Optional, Union
|
9
|
+
|
10
|
+
|
11
|
+
class AudioSource:
|
12
|
+
"""Abstract base class for audio sources."""
|
13
|
+
|
14
|
+
async def read(self) -> bytes:
|
15
|
+
"""Read the next chunk of PCM audio.
|
16
|
+
|
17
|
+
Subclasses must implement this and return raw PCM data
|
18
|
+
at 48kHz stereo (3840 byte chunks).
|
19
|
+
"""
|
20
|
+
|
21
|
+
raise NotImplementedError
|
22
|
+
|
23
|
+
async def close(self) -> None:
|
24
|
+
"""Cleanup the source when playback ends."""
|
25
|
+
|
26
|
+
return None
|
27
|
+
|
28
|
+
|
29
|
+
class FFmpegAudioSource(AudioSource):
|
30
|
+
"""Decode audio using FFmpeg.
|
31
|
+
|
32
|
+
Parameters
|
33
|
+
----------
|
34
|
+
source:
|
35
|
+
A filename, URL, or file-like object to read from.
|
36
|
+
"""
|
37
|
+
|
38
|
+
def __init__(self, source: Union[str, io.BufferedIOBase]):
|
39
|
+
self.source = source
|
40
|
+
self.process: Optional[asyncio.subprocess.Process] = None
|
41
|
+
self._feeder: Optional[asyncio.Task] = None
|
42
|
+
|
43
|
+
async def _spawn(self) -> None:
|
44
|
+
if isinstance(self.source, str):
|
45
|
+
args = [
|
46
|
+
"ffmpeg",
|
47
|
+
"-i",
|
48
|
+
self.source,
|
49
|
+
"-f",
|
50
|
+
"s16le",
|
51
|
+
"-ar",
|
52
|
+
"48000",
|
53
|
+
"-ac",
|
54
|
+
"2",
|
55
|
+
"pipe:1",
|
56
|
+
]
|
57
|
+
self.process = await asyncio.create_subprocess_exec(
|
58
|
+
*args,
|
59
|
+
stdout=asyncio.subprocess.PIPE,
|
60
|
+
stderr=asyncio.subprocess.DEVNULL,
|
61
|
+
)
|
62
|
+
else:
|
63
|
+
args = [
|
64
|
+
"ffmpeg",
|
65
|
+
"-i",
|
66
|
+
"pipe:0",
|
67
|
+
"-f",
|
68
|
+
"s16le",
|
69
|
+
"-ar",
|
70
|
+
"48000",
|
71
|
+
"-ac",
|
72
|
+
"2",
|
73
|
+
"pipe:1",
|
74
|
+
]
|
75
|
+
self.process = await asyncio.create_subprocess_exec(
|
76
|
+
*args,
|
77
|
+
stdin=asyncio.subprocess.PIPE,
|
78
|
+
stdout=asyncio.subprocess.PIPE,
|
79
|
+
stderr=asyncio.subprocess.DEVNULL,
|
80
|
+
)
|
81
|
+
assert self.process.stdin is not None
|
82
|
+
self._feeder = asyncio.create_task(self._feed())
|
83
|
+
|
84
|
+
async def _feed(self) -> None:
|
85
|
+
assert isinstance(self.source, io.BufferedIOBase)
|
86
|
+
assert self.process is not None
|
87
|
+
assert self.process.stdin is not None
|
88
|
+
while True:
|
89
|
+
data = await asyncio.to_thread(self.source.read, 4096)
|
90
|
+
if not data:
|
91
|
+
break
|
92
|
+
self.process.stdin.write(data)
|
93
|
+
await self.process.stdin.drain()
|
94
|
+
self.process.stdin.close()
|
95
|
+
|
96
|
+
async def read(self) -> bytes:
|
97
|
+
if self.process is None:
|
98
|
+
await self._spawn()
|
99
|
+
assert self.process is not None
|
100
|
+
assert self.process.stdout is not None
|
101
|
+
data = await self.process.stdout.read(3840)
|
102
|
+
if not data:
|
103
|
+
await self.close()
|
104
|
+
return data
|
105
|
+
|
106
|
+
async def close(self) -> None:
|
107
|
+
if self._feeder:
|
108
|
+
self._feeder.cancel()
|
109
|
+
with contextlib.suppress(asyncio.CancelledError):
|
110
|
+
await self._feeder
|
111
|
+
if self.process:
|
112
|
+
await self.process.wait()
|
113
|
+
self.process = None
|
114
|
+
if isinstance(self.source, io.IOBase):
|
115
|
+
with contextlib.suppress(Exception):
|
116
|
+
self.source.close()
|
disagreement/client.py
CHANGED
@@ -16,20 +16,23 @@ 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
|
22
23
|
from .shard_manager import ShardManager
|
23
24
|
from .event_dispatcher import EventDispatcher
|
24
|
-
from .enums import GatewayIntent, InteractionType
|
25
|
+
from .enums import GatewayIntent, InteractionType, GatewayOpcode
|
25
26
|
from .errors import DisagreementException, AuthenticationError
|
26
27
|
from .typing import Typing
|
27
28
|
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
|
35
|
+
from .voice_client import VoiceClient
|
33
36
|
|
34
37
|
if TYPE_CHECKING:
|
35
38
|
from .models import (
|
@@ -46,6 +49,7 @@ if TYPE_CHECKING:
|
|
46
49
|
CategoryChannel,
|
47
50
|
Thread,
|
48
51
|
DMChannel,
|
52
|
+
Webhook,
|
49
53
|
)
|
50
54
|
from .ui.view import View
|
51
55
|
from .enums import ChannelType as EnumChannelType
|
@@ -82,6 +86,8 @@ class Client:
|
|
82
86
|
verbose: bool = False,
|
83
87
|
mention_replies: bool = False,
|
84
88
|
shard_count: Optional[int] = None,
|
89
|
+
gateway_max_retries: int = 5,
|
90
|
+
gateway_max_backoff: float = 60.0,
|
85
91
|
):
|
86
92
|
if not token:
|
87
93
|
raise ValueError("A bot token must be provided.")
|
@@ -101,6 +107,8 @@ class Client:
|
|
101
107
|
None # Initialized in run() or connect()
|
102
108
|
)
|
103
109
|
self.shard_count: Optional[int] = shard_count
|
110
|
+
self.gateway_max_retries: int = gateway_max_retries
|
111
|
+
self.gateway_max_backoff: float = gateway_max_backoff
|
104
112
|
self._shard_manager: Optional[ShardManager] = None
|
105
113
|
|
106
114
|
# Initialize CommandHandler
|
@@ -133,6 +141,8 @@ class Client:
|
|
133
141
|
) # Placeholder for User model cache if needed
|
134
142
|
self._messages: Dict[Snowflake, "Message"] = {}
|
135
143
|
self._views: Dict[Snowflake, "View"] = {}
|
144
|
+
self._voice_clients: Dict[Snowflake, VoiceClient] = {}
|
145
|
+
self._webhooks: Dict[Snowflake, "Webhook"] = {}
|
136
146
|
|
137
147
|
# Default whether replies mention the user
|
138
148
|
self.mention_replies: bool = mention_replies
|
@@ -165,6 +175,8 @@ class Client:
|
|
165
175
|
intents=self.intents,
|
166
176
|
client_instance=self,
|
167
177
|
verbose=self.verbose,
|
178
|
+
max_retries=self.gateway_max_retries,
|
179
|
+
max_backoff=self.gateway_max_backoff,
|
168
180
|
)
|
169
181
|
|
170
182
|
async def _initialize_shard_manager(self) -> None:
|
@@ -361,6 +373,13 @@ class Client:
|
|
361
373
|
"""Indicates if the client has successfully connected to the Gateway and is ready."""
|
362
374
|
return self._ready_event.is_set()
|
363
375
|
|
376
|
+
@property
|
377
|
+
def latency(self) -> Optional[float]:
|
378
|
+
"""Returns the gateway latency in seconds, or ``None`` if unavailable."""
|
379
|
+
if self._gateway:
|
380
|
+
return self._gateway.latency
|
381
|
+
return None
|
382
|
+
|
364
383
|
async def wait_until_ready(self) -> None:
|
365
384
|
"""|coro|
|
366
385
|
Waits until the client is fully connected to Discord and the initial state is processed.
|
@@ -448,11 +467,16 @@ class Client:
|
|
448
467
|
# Map common function names to Discord event types
|
449
468
|
# e.g., on_ready -> READY, on_message -> MESSAGE_CREATE
|
450
469
|
if event_name.startswith("on_"):
|
451
|
-
discord_event_name = event_name[3:].upper()
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
470
|
+
discord_event_name = event_name[3:].upper()
|
471
|
+
mapping = {
|
472
|
+
"MESSAGE": "MESSAGE_CREATE",
|
473
|
+
"MESSAGE_EDIT": "MESSAGE_UPDATE",
|
474
|
+
"MESSAGE_UPDATE": "MESSAGE_UPDATE",
|
475
|
+
"MESSAGE_DELETE": "MESSAGE_DELETE",
|
476
|
+
"REACTION_ADD": "MESSAGE_REACTION_ADD",
|
477
|
+
"REACTION_REMOVE": "MESSAGE_REACTION_REMOVE",
|
478
|
+
}
|
479
|
+
discord_event_name = mapping.get(discord_event_name, discord_event_name)
|
456
480
|
self._event_dispatcher.register(discord_event_name, coro)
|
457
481
|
else:
|
458
482
|
# If not starting with "on_", assume it's the direct Discord event name (e.g. "TYPING_START")
|
@@ -616,6 +640,23 @@ class Client:
|
|
616
640
|
# import traceback
|
617
641
|
# traceback.print_exception(type(error.original), error.original, error.original.__traceback__)
|
618
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
|
+
|
619
660
|
# --- Model Parsing and Fetching ---
|
620
661
|
|
621
662
|
def parse_user(self, data: Dict[str, Any]) -> "User":
|
@@ -648,6 +689,19 @@ class Client:
|
|
648
689
|
self._messages[message.id] = message
|
649
690
|
return message
|
650
691
|
|
692
|
+
def parse_webhook(self, data: Union[Dict[str, Any], "Webhook"]) -> "Webhook":
|
693
|
+
"""Parses webhook data and returns a Webhook object, updating cache."""
|
694
|
+
|
695
|
+
from .models import Webhook
|
696
|
+
|
697
|
+
if isinstance(data, Webhook):
|
698
|
+
webhook = data
|
699
|
+
webhook._client = self # type: ignore[attr-defined]
|
700
|
+
else:
|
701
|
+
webhook = Webhook(data, client_instance=self)
|
702
|
+
self._webhooks[webhook.id] = webhook
|
703
|
+
return webhook
|
704
|
+
|
651
705
|
async def fetch_user(self, user_id: Snowflake) -> Optional["User"]:
|
652
706
|
"""Fetches a user by ID from Discord."""
|
653
707
|
if self._closed:
|
@@ -830,6 +884,8 @@ class Client:
|
|
830
884
|
components: Optional[List["ActionRow"]] = None,
|
831
885
|
allowed_mentions: Optional[Dict[str, Any]] = None,
|
832
886
|
message_reference: Optional[Dict[str, Any]] = None,
|
887
|
+
attachments: Optional[List[Any]] = None,
|
888
|
+
files: Optional[List[Any]] = None,
|
833
889
|
flags: Optional[int] = None,
|
834
890
|
view: Optional["View"] = None,
|
835
891
|
) -> "Message":
|
@@ -846,6 +902,8 @@ class Client:
|
|
846
902
|
components (Optional[List[ActionRow]]): A list of ActionRow components to include.
|
847
903
|
allowed_mentions (Optional[Dict[str, Any]]): Allowed mentions for the message.
|
848
904
|
message_reference (Optional[Dict[str, Any]]): Message reference for replying.
|
905
|
+
attachments (Optional[List[Any]]): Attachments to include with the message.
|
906
|
+
files (Optional[List[Any]]): Files to upload with the message.
|
849
907
|
flags (Optional[int]): Message flags.
|
850
908
|
view (Optional[View]): A view to send with the message.
|
851
909
|
|
@@ -881,12 +939,12 @@ class Client:
|
|
881
939
|
await view._start(self)
|
882
940
|
components_payload = view.to_components_payload()
|
883
941
|
elif components:
|
884
|
-
from .models import
|
942
|
+
from .models import Component as ComponentModel
|
885
943
|
|
886
944
|
components_payload = [
|
887
945
|
comp.to_dict()
|
888
946
|
for comp in components
|
889
|
-
if isinstance(comp,
|
947
|
+
if isinstance(comp, ComponentModel)
|
890
948
|
]
|
891
949
|
|
892
950
|
message_data = await self._http.send_message(
|
@@ -897,6 +955,8 @@ class Client:
|
|
897
955
|
components=components_payload,
|
898
956
|
allowed_mentions=allowed_mentions,
|
899
957
|
message_reference=message_reference,
|
958
|
+
attachments=attachments,
|
959
|
+
files=files,
|
900
960
|
flags=flags,
|
901
961
|
)
|
902
962
|
|
@@ -912,6 +972,89 @@ class Client:
|
|
912
972
|
|
913
973
|
return Typing(self, channel_id)
|
914
974
|
|
975
|
+
async def join_voice(
|
976
|
+
self,
|
977
|
+
guild_id: Snowflake,
|
978
|
+
channel_id: Snowflake,
|
979
|
+
*,
|
980
|
+
self_mute: bool = False,
|
981
|
+
self_deaf: bool = False,
|
982
|
+
) -> VoiceClient:
|
983
|
+
"""|coro| Join a voice channel and return a :class:`VoiceClient`."""
|
984
|
+
|
985
|
+
if self._closed:
|
986
|
+
raise DisagreementException("Client is closed.")
|
987
|
+
if not self.is_ready():
|
988
|
+
await self.wait_until_ready()
|
989
|
+
if self._gateway is None:
|
990
|
+
raise DisagreementException("Gateway is not connected.")
|
991
|
+
if not self.user:
|
992
|
+
raise DisagreementException("Client user unavailable.")
|
993
|
+
assert self.user is not None
|
994
|
+
user_id = self.user.id
|
995
|
+
|
996
|
+
if guild_id in self._voice_clients:
|
997
|
+
return self._voice_clients[guild_id]
|
998
|
+
|
999
|
+
payload = {
|
1000
|
+
"op": GatewayOpcode.VOICE_STATE_UPDATE,
|
1001
|
+
"d": {
|
1002
|
+
"guild_id": str(guild_id),
|
1003
|
+
"channel_id": str(channel_id),
|
1004
|
+
"self_mute": self_mute,
|
1005
|
+
"self_deaf": self_deaf,
|
1006
|
+
},
|
1007
|
+
}
|
1008
|
+
await self._gateway._send_json(payload) # type: ignore[attr-defined]
|
1009
|
+
|
1010
|
+
server = await self.wait_for(
|
1011
|
+
"VOICE_SERVER_UPDATE",
|
1012
|
+
check=lambda d: d.get("guild_id") == str(guild_id),
|
1013
|
+
timeout=10,
|
1014
|
+
)
|
1015
|
+
state = await self.wait_for(
|
1016
|
+
"VOICE_STATE_UPDATE",
|
1017
|
+
check=lambda d, uid=user_id: d.get("guild_id") == str(guild_id)
|
1018
|
+
and d.get("user_id") == str(uid),
|
1019
|
+
timeout=10,
|
1020
|
+
)
|
1021
|
+
|
1022
|
+
endpoint = f"wss://{server['endpoint']}?v=10"
|
1023
|
+
token = server["token"]
|
1024
|
+
session_id = state["session_id"]
|
1025
|
+
|
1026
|
+
voice = VoiceClient(
|
1027
|
+
endpoint,
|
1028
|
+
session_id,
|
1029
|
+
token,
|
1030
|
+
int(guild_id),
|
1031
|
+
int(self.user.id),
|
1032
|
+
verbose=self.verbose,
|
1033
|
+
)
|
1034
|
+
await voice.connect()
|
1035
|
+
self._voice_clients[guild_id] = voice
|
1036
|
+
return voice
|
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
|
+
|
915
1058
|
async def create_reaction(
|
916
1059
|
self, channel_id: str, message_id: str, emoji: str
|
917
1060
|
) -> None:
|
@@ -922,6 +1065,16 @@ class Client:
|
|
922
1065
|
|
923
1066
|
await self._http.create_reaction(channel_id, message_id, emoji)
|
924
1067
|
|
1068
|
+
user_id = getattr(getattr(self, "user", None), "id", None)
|
1069
|
+
payload = {
|
1070
|
+
"user_id": user_id,
|
1071
|
+
"channel_id": channel_id,
|
1072
|
+
"message_id": message_id,
|
1073
|
+
"emoji": {"name": emoji, "id": None},
|
1074
|
+
}
|
1075
|
+
if hasattr(self, "_event_dispatcher"):
|
1076
|
+
await self._event_dispatcher.dispatch("MESSAGE_REACTION_ADD", payload)
|
1077
|
+
|
925
1078
|
async def delete_reaction(
|
926
1079
|
self, channel_id: str, message_id: str, emoji: str
|
927
1080
|
) -> None:
|
@@ -932,6 +1085,16 @@ class Client:
|
|
932
1085
|
|
933
1086
|
await self._http.delete_reaction(channel_id, message_id, emoji)
|
934
1087
|
|
1088
|
+
user_id = getattr(getattr(self, "user", None), "id", None)
|
1089
|
+
payload = {
|
1090
|
+
"user_id": user_id,
|
1091
|
+
"channel_id": channel_id,
|
1092
|
+
"message_id": message_id,
|
1093
|
+
"emoji": {"name": emoji, "id": None},
|
1094
|
+
}
|
1095
|
+
if hasattr(self, "_event_dispatcher"):
|
1096
|
+
await self._event_dispatcher.dispatch("MESSAGE_REACTION_REMOVE", payload)
|
1097
|
+
|
935
1098
|
async def get_reactions(
|
936
1099
|
self, channel_id: str, message_id: str, emoji: str
|
937
1100
|
) -> List["User"]:
|
@@ -1060,6 +1223,36 @@ class Client:
|
|
1060
1223
|
print(f"Failed to fetch channel {channel_id}: {e}")
|
1061
1224
|
return None
|
1062
1225
|
|
1226
|
+
async def create_webhook(
|
1227
|
+
self, channel_id: Snowflake, payload: Dict[str, Any]
|
1228
|
+
) -> "Webhook":
|
1229
|
+
"""|coro| Create a webhook in the given channel."""
|
1230
|
+
|
1231
|
+
if self._closed:
|
1232
|
+
raise DisagreementException("Client is closed.")
|
1233
|
+
|
1234
|
+
data = await self._http.create_webhook(channel_id, payload)
|
1235
|
+
return self.parse_webhook(data)
|
1236
|
+
|
1237
|
+
async def edit_webhook(
|
1238
|
+
self, webhook_id: Snowflake, payload: Dict[str, Any]
|
1239
|
+
) -> "Webhook":
|
1240
|
+
"""|coro| Edit an existing webhook."""
|
1241
|
+
|
1242
|
+
if self._closed:
|
1243
|
+
raise DisagreementException("Client is closed.")
|
1244
|
+
|
1245
|
+
data = await self._http.edit_webhook(webhook_id, payload)
|
1246
|
+
return self.parse_webhook(data)
|
1247
|
+
|
1248
|
+
async def delete_webhook(self, webhook_id: Snowflake) -> None:
|
1249
|
+
"""|coro| Delete a webhook by ID."""
|
1250
|
+
|
1251
|
+
if self._closed:
|
1252
|
+
raise DisagreementException("Client is closed.")
|
1253
|
+
|
1254
|
+
await self._http.delete_webhook(webhook_id)
|
1255
|
+
|
1063
1256
|
# --- Application Command Methods ---
|
1064
1257
|
async def process_interaction(self, interaction: Interaction) -> None:
|
1065
1258
|
"""Internal method to process an interaction from the gateway."""
|
@@ -1142,3 +1335,19 @@ class Client:
|
|
1142
1335
|
|
1143
1336
|
print(f"Unhandled exception in event listener for '{event_method}':")
|
1144
1337
|
print(f"{type(exc).__name__}: {exc}")
|
1338
|
+
|
1339
|
+
|
1340
|
+
class AutoShardedClient(Client):
|
1341
|
+
"""A :class:`Client` that automatically determines the shard count.
|
1342
|
+
|
1343
|
+
If ``shard_count`` is not provided, the client will query the Discord API
|
1344
|
+
via :meth:`HTTPClient.get_gateway_bot` for the recommended shard count and
|
1345
|
+
use that when connecting.
|
1346
|
+
"""
|
1347
|
+
|
1348
|
+
async def connect(self, reconnect: bool = True) -> None: # type: ignore[override]
|
1349
|
+
if self.shard_count is None:
|
1350
|
+
data = await self._http.get_gateway_bot()
|
1351
|
+
self.shard_count = data.get("shards", 1)
|
1352
|
+
|
1353
|
+
await super().connect(reconnect=reconnect)
|
disagreement/color.py
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
"""Simple color helper similar to discord.py's Color class."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from dataclasses import dataclass
|
6
|
+
|
7
|
+
|
8
|
+
@dataclass(frozen=True)
|
9
|
+
class Color:
|
10
|
+
"""Represents an RGB color value."""
|
11
|
+
|
12
|
+
value: int
|
13
|
+
|
14
|
+
def __post_init__(self) -> None:
|
15
|
+
if not 0 <= self.value <= 0xFFFFFF:
|
16
|
+
raise ValueError("Color value must be between 0x000000 and 0xFFFFFF")
|
17
|
+
|
18
|
+
@classmethod
|
19
|
+
def from_rgb(cls, r: int, g: int, b: int) -> "Color":
|
20
|
+
"""Create a Color from red, green and blue components."""
|
21
|
+
if not all(0 <= c <= 255 for c in (r, g, b)):
|
22
|
+
raise ValueError("RGB components must be between 0 and 255")
|
23
|
+
return cls((r << 16) + (g << 8) + b)
|
24
|
+
|
25
|
+
@classmethod
|
26
|
+
def from_hex(cls, value: str) -> "Color":
|
27
|
+
"""Create a Color from a hex string like ``"#ff0000"``."""
|
28
|
+
value = value.lstrip("#")
|
29
|
+
if len(value) != 6:
|
30
|
+
raise ValueError("Hex string must be in RRGGBB format")
|
31
|
+
return cls(int(value, 16))
|
32
|
+
|
33
|
+
@classmethod
|
34
|
+
def default(cls) -> "Color":
|
35
|
+
return cls(0)
|
36
|
+
|
37
|
+
@classmethod
|
38
|
+
def red(cls) -> "Color":
|
39
|
+
return cls(0xFF0000)
|
40
|
+
|
41
|
+
@classmethod
|
42
|
+
def green(cls) -> "Color":
|
43
|
+
return cls(0x00FF00)
|
44
|
+
|
45
|
+
@classmethod
|
46
|
+
def blue(cls) -> "Color":
|
47
|
+
return cls(0x0000FF)
|
48
|
+
|
49
|
+
def to_rgb(self) -> tuple[int, int, int]:
|
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/components.py
CHANGED
@@ -18,7 +18,7 @@ from .models import (
|
|
18
18
|
Thumbnail,
|
19
19
|
MediaGallery,
|
20
20
|
MediaGalleryItem,
|
21
|
-
|
21
|
+
FileComponent,
|
22
22
|
Separator,
|
23
23
|
Container,
|
24
24
|
UnfurledMediaItem,
|
@@ -140,7 +140,7 @@ def component_factory(
|
|
140
140
|
)
|
141
141
|
|
142
142
|
if ctype == ComponentType.FILE:
|
143
|
-
return
|
143
|
+
return FileComponent(
|
144
144
|
file=UnfurledMediaItem(**data["file"]),
|
145
145
|
spoiler=data.get("spoiler", False),
|
146
146
|
id=data.get("id"),
|
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)."""
|
disagreement/errors.py
CHANGED
@@ -77,14 +77,19 @@ class RateLimitError(HTTPException):
|
|
77
77
|
)
|
78
78
|
|
79
79
|
|
80
|
-
#
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
80
|
+
# Specific HTTP error exceptions
|
81
|
+
|
82
|
+
|
83
|
+
class NotFound(HTTPException):
|
84
|
+
"""Raised for 404 Not Found errors."""
|
85
|
+
|
86
|
+
pass
|
87
|
+
|
88
|
+
|
89
|
+
class Forbidden(HTTPException):
|
90
|
+
"""Raised for 403 Forbidden errors."""
|
91
|
+
|
92
|
+
pass
|
88
93
|
|
89
94
|
|
90
95
|
class AppCommandError(DisagreementException):
|