disagreement 0.0.2__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.
- disagreement/__init__.py +8 -3
- disagreement/audio.py +116 -0
- disagreement/client.py +176 -6
- disagreement/color.py +50 -0
- disagreement/components.py +2 -2
- disagreement/errors.py +13 -8
- disagreement/event_dispatcher.py +102 -45
- disagreement/ext/commands/__init__.py +9 -1
- disagreement/ext/commands/core.py +7 -0
- disagreement/ext/commands/decorators.py +72 -30
- disagreement/ext/loader.py +12 -1
- disagreement/ext/tasks.py +101 -8
- disagreement/gateway.py +56 -13
- disagreement/http.py +104 -3
- disagreement/models.py +308 -1
- disagreement/shard_manager.py +2 -0
- disagreement/utils.py +10 -0
- disagreement/voice_client.py +42 -0
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc1.dist-info}/METADATA +9 -2
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc1.dist-info}/RECORD +23 -20
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc1.dist-info}/WHEEL +0 -0
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc1.dist-info}/licenses/LICENSE +0 -0
- {disagreement-0.0.2.dist-info → disagreement-0.1.0rc1.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.0rc1"
|
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
|
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
@@ -21,7 +21,7 @@ from .http import HTTPClient
|
|
21
21
|
from .gateway import GatewayClient
|
22
22
|
from .shard_manager import ShardManager
|
23
23
|
from .event_dispatcher import EventDispatcher
|
24
|
-
from .enums import GatewayIntent, InteractionType
|
24
|
+
from .enums import GatewayIntent, InteractionType, GatewayOpcode
|
25
25
|
from .errors import DisagreementException, AuthenticationError
|
26
26
|
from .typing import Typing
|
27
27
|
from .ext.commands.core import CommandHandler
|
@@ -30,6 +30,7 @@ from .ext.app_commands.handler import AppCommandHandler
|
|
30
30
|
from .ext.app_commands.context import AppCommandContext
|
31
31
|
from .interactions import Interaction, Snowflake
|
32
32
|
from .error_handler import setup_global_error_handler
|
33
|
+
from .voice_client import VoiceClient
|
33
34
|
|
34
35
|
if TYPE_CHECKING:
|
35
36
|
from .models import (
|
@@ -46,6 +47,7 @@ if TYPE_CHECKING:
|
|
46
47
|
CategoryChannel,
|
47
48
|
Thread,
|
48
49
|
DMChannel,
|
50
|
+
Webhook,
|
49
51
|
)
|
50
52
|
from .ui.view import View
|
51
53
|
from .enums import ChannelType as EnumChannelType
|
@@ -82,6 +84,8 @@ class Client:
|
|
82
84
|
verbose: bool = False,
|
83
85
|
mention_replies: bool = False,
|
84
86
|
shard_count: Optional[int] = None,
|
87
|
+
gateway_max_retries: int = 5,
|
88
|
+
gateway_max_backoff: float = 60.0,
|
85
89
|
):
|
86
90
|
if not token:
|
87
91
|
raise ValueError("A bot token must be provided.")
|
@@ -101,6 +105,8 @@ class Client:
|
|
101
105
|
None # Initialized in run() or connect()
|
102
106
|
)
|
103
107
|
self.shard_count: Optional[int] = shard_count
|
108
|
+
self.gateway_max_retries: int = gateway_max_retries
|
109
|
+
self.gateway_max_backoff: float = gateway_max_backoff
|
104
110
|
self._shard_manager: Optional[ShardManager] = None
|
105
111
|
|
106
112
|
# Initialize CommandHandler
|
@@ -133,6 +139,8 @@ class Client:
|
|
133
139
|
) # Placeholder for User model cache if needed
|
134
140
|
self._messages: Dict[Snowflake, "Message"] = {}
|
135
141
|
self._views: Dict[Snowflake, "View"] = {}
|
142
|
+
self._voice_clients: Dict[Snowflake, VoiceClient] = {}
|
143
|
+
self._webhooks: Dict[Snowflake, "Webhook"] = {}
|
136
144
|
|
137
145
|
# Default whether replies mention the user
|
138
146
|
self.mention_replies: bool = mention_replies
|
@@ -165,6 +173,8 @@ class Client:
|
|
165
173
|
intents=self.intents,
|
166
174
|
client_instance=self,
|
167
175
|
verbose=self.verbose,
|
176
|
+
max_retries=self.gateway_max_retries,
|
177
|
+
max_backoff=self.gateway_max_backoff,
|
168
178
|
)
|
169
179
|
|
170
180
|
async def _initialize_shard_manager(self) -> None:
|
@@ -361,6 +371,13 @@ class Client:
|
|
361
371
|
"""Indicates if the client has successfully connected to the Gateway and is ready."""
|
362
372
|
return self._ready_event.is_set()
|
363
373
|
|
374
|
+
@property
|
375
|
+
def latency(self) -> Optional[float]:
|
376
|
+
"""Returns the gateway latency in seconds, or ``None`` if unavailable."""
|
377
|
+
if self._gateway:
|
378
|
+
return self._gateway.latency
|
379
|
+
return None
|
380
|
+
|
364
381
|
async def wait_until_ready(self) -> None:
|
365
382
|
"""|coro|
|
366
383
|
Waits until the client is fully connected to Discord and the initial state is processed.
|
@@ -448,11 +465,16 @@ class Client:
|
|
448
465
|
# Map common function names to Discord event types
|
449
466
|
# e.g., on_ready -> READY, on_message -> MESSAGE_CREATE
|
450
467
|
if event_name.startswith("on_"):
|
451
|
-
discord_event_name = event_name[3:].upper()
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
468
|
+
discord_event_name = event_name[3:].upper()
|
469
|
+
mapping = {
|
470
|
+
"MESSAGE": "MESSAGE_CREATE",
|
471
|
+
"MESSAGE_EDIT": "MESSAGE_UPDATE",
|
472
|
+
"MESSAGE_UPDATE": "MESSAGE_UPDATE",
|
473
|
+
"MESSAGE_DELETE": "MESSAGE_DELETE",
|
474
|
+
"REACTION_ADD": "MESSAGE_REACTION_ADD",
|
475
|
+
"REACTION_REMOVE": "MESSAGE_REACTION_REMOVE",
|
476
|
+
}
|
477
|
+
discord_event_name = mapping.get(discord_event_name, discord_event_name)
|
456
478
|
self._event_dispatcher.register(discord_event_name, coro)
|
457
479
|
else:
|
458
480
|
# If not starting with "on_", assume it's the direct Discord event name (e.g. "TYPING_START")
|
@@ -648,6 +670,19 @@ class Client:
|
|
648
670
|
self._messages[message.id] = message
|
649
671
|
return message
|
650
672
|
|
673
|
+
def parse_webhook(self, data: Union[Dict[str, Any], "Webhook"]) -> "Webhook":
|
674
|
+
"""Parses webhook data and returns a Webhook object, updating cache."""
|
675
|
+
|
676
|
+
from .models import Webhook
|
677
|
+
|
678
|
+
if isinstance(data, Webhook):
|
679
|
+
webhook = data
|
680
|
+
webhook._client = self # type: ignore[attr-defined]
|
681
|
+
else:
|
682
|
+
webhook = Webhook(data, client_instance=self)
|
683
|
+
self._webhooks[webhook.id] = webhook
|
684
|
+
return webhook
|
685
|
+
|
651
686
|
async def fetch_user(self, user_id: Snowflake) -> Optional["User"]:
|
652
687
|
"""Fetches a user by ID from Discord."""
|
653
688
|
if self._closed:
|
@@ -830,6 +865,8 @@ class Client:
|
|
830
865
|
components: Optional[List["ActionRow"]] = None,
|
831
866
|
allowed_mentions: Optional[Dict[str, Any]] = None,
|
832
867
|
message_reference: Optional[Dict[str, Any]] = None,
|
868
|
+
attachments: Optional[List[Any]] = None,
|
869
|
+
files: Optional[List[Any]] = None,
|
833
870
|
flags: Optional[int] = None,
|
834
871
|
view: Optional["View"] = None,
|
835
872
|
) -> "Message":
|
@@ -846,6 +883,8 @@ class Client:
|
|
846
883
|
components (Optional[List[ActionRow]]): A list of ActionRow components to include.
|
847
884
|
allowed_mentions (Optional[Dict[str, Any]]): Allowed mentions for the message.
|
848
885
|
message_reference (Optional[Dict[str, Any]]): Message reference for replying.
|
886
|
+
attachments (Optional[List[Any]]): Attachments to include with the message.
|
887
|
+
files (Optional[List[Any]]): Files to upload with the message.
|
849
888
|
flags (Optional[int]): Message flags.
|
850
889
|
view (Optional[View]): A view to send with the message.
|
851
890
|
|
@@ -897,6 +936,8 @@ class Client:
|
|
897
936
|
components=components_payload,
|
898
937
|
allowed_mentions=allowed_mentions,
|
899
938
|
message_reference=message_reference,
|
939
|
+
attachments=attachments,
|
940
|
+
files=files,
|
900
941
|
flags=flags,
|
901
942
|
)
|
902
943
|
|
@@ -912,6 +953,69 @@ class Client:
|
|
912
953
|
|
913
954
|
return Typing(self, channel_id)
|
914
955
|
|
956
|
+
async def join_voice(
|
957
|
+
self,
|
958
|
+
guild_id: Snowflake,
|
959
|
+
channel_id: Snowflake,
|
960
|
+
*,
|
961
|
+
self_mute: bool = False,
|
962
|
+
self_deaf: bool = False,
|
963
|
+
) -> VoiceClient:
|
964
|
+
"""|coro| Join a voice channel and return a :class:`VoiceClient`."""
|
965
|
+
|
966
|
+
if self._closed:
|
967
|
+
raise DisagreementException("Client is closed.")
|
968
|
+
if not self.is_ready():
|
969
|
+
await self.wait_until_ready()
|
970
|
+
if self._gateway is None:
|
971
|
+
raise DisagreementException("Gateway is not connected.")
|
972
|
+
if not self.user:
|
973
|
+
raise DisagreementException("Client user unavailable.")
|
974
|
+
assert self.user is not None
|
975
|
+
user_id = self.user.id
|
976
|
+
|
977
|
+
if guild_id in self._voice_clients:
|
978
|
+
return self._voice_clients[guild_id]
|
979
|
+
|
980
|
+
payload = {
|
981
|
+
"op": GatewayOpcode.VOICE_STATE_UPDATE,
|
982
|
+
"d": {
|
983
|
+
"guild_id": str(guild_id),
|
984
|
+
"channel_id": str(channel_id),
|
985
|
+
"self_mute": self_mute,
|
986
|
+
"self_deaf": self_deaf,
|
987
|
+
},
|
988
|
+
}
|
989
|
+
await self._gateway._send_json(payload) # type: ignore[attr-defined]
|
990
|
+
|
991
|
+
server = await self.wait_for(
|
992
|
+
"VOICE_SERVER_UPDATE",
|
993
|
+
check=lambda d: d.get("guild_id") == str(guild_id),
|
994
|
+
timeout=10,
|
995
|
+
)
|
996
|
+
state = await self.wait_for(
|
997
|
+
"VOICE_STATE_UPDATE",
|
998
|
+
check=lambda d, uid=user_id: d.get("guild_id") == str(guild_id)
|
999
|
+
and d.get("user_id") == str(uid),
|
1000
|
+
timeout=10,
|
1001
|
+
)
|
1002
|
+
|
1003
|
+
endpoint = f"wss://{server['endpoint']}?v=10"
|
1004
|
+
token = server["token"]
|
1005
|
+
session_id = state["session_id"]
|
1006
|
+
|
1007
|
+
voice = VoiceClient(
|
1008
|
+
endpoint,
|
1009
|
+
session_id,
|
1010
|
+
token,
|
1011
|
+
int(guild_id),
|
1012
|
+
int(self.user.id),
|
1013
|
+
verbose=self.verbose,
|
1014
|
+
)
|
1015
|
+
await voice.connect()
|
1016
|
+
self._voice_clients[guild_id] = voice
|
1017
|
+
return voice
|
1018
|
+
|
915
1019
|
async def create_reaction(
|
916
1020
|
self, channel_id: str, message_id: str, emoji: str
|
917
1021
|
) -> None:
|
@@ -922,6 +1026,16 @@ class Client:
|
|
922
1026
|
|
923
1027
|
await self._http.create_reaction(channel_id, message_id, emoji)
|
924
1028
|
|
1029
|
+
user_id = getattr(getattr(self, "user", None), "id", None)
|
1030
|
+
payload = {
|
1031
|
+
"user_id": user_id,
|
1032
|
+
"channel_id": channel_id,
|
1033
|
+
"message_id": message_id,
|
1034
|
+
"emoji": {"name": emoji, "id": None},
|
1035
|
+
}
|
1036
|
+
if hasattr(self, "_event_dispatcher"):
|
1037
|
+
await self._event_dispatcher.dispatch("MESSAGE_REACTION_ADD", payload)
|
1038
|
+
|
925
1039
|
async def delete_reaction(
|
926
1040
|
self, channel_id: str, message_id: str, emoji: str
|
927
1041
|
) -> None:
|
@@ -932,6 +1046,16 @@ class Client:
|
|
932
1046
|
|
933
1047
|
await self._http.delete_reaction(channel_id, message_id, emoji)
|
934
1048
|
|
1049
|
+
user_id = getattr(getattr(self, "user", None), "id", None)
|
1050
|
+
payload = {
|
1051
|
+
"user_id": user_id,
|
1052
|
+
"channel_id": channel_id,
|
1053
|
+
"message_id": message_id,
|
1054
|
+
"emoji": {"name": emoji, "id": None},
|
1055
|
+
}
|
1056
|
+
if hasattr(self, "_event_dispatcher"):
|
1057
|
+
await self._event_dispatcher.dispatch("MESSAGE_REACTION_REMOVE", payload)
|
1058
|
+
|
935
1059
|
async def get_reactions(
|
936
1060
|
self, channel_id: str, message_id: str, emoji: str
|
937
1061
|
) -> List["User"]:
|
@@ -1060,6 +1184,36 @@ class Client:
|
|
1060
1184
|
print(f"Failed to fetch channel {channel_id}: {e}")
|
1061
1185
|
return None
|
1062
1186
|
|
1187
|
+
async def create_webhook(
|
1188
|
+
self, channel_id: Snowflake, payload: Dict[str, Any]
|
1189
|
+
) -> "Webhook":
|
1190
|
+
"""|coro| Create a webhook in the given channel."""
|
1191
|
+
|
1192
|
+
if self._closed:
|
1193
|
+
raise DisagreementException("Client is closed.")
|
1194
|
+
|
1195
|
+
data = await self._http.create_webhook(channel_id, payload)
|
1196
|
+
return self.parse_webhook(data)
|
1197
|
+
|
1198
|
+
async def edit_webhook(
|
1199
|
+
self, webhook_id: Snowflake, payload: Dict[str, Any]
|
1200
|
+
) -> "Webhook":
|
1201
|
+
"""|coro| Edit an existing webhook."""
|
1202
|
+
|
1203
|
+
if self._closed:
|
1204
|
+
raise DisagreementException("Client is closed.")
|
1205
|
+
|
1206
|
+
data = await self._http.edit_webhook(webhook_id, payload)
|
1207
|
+
return self.parse_webhook(data)
|
1208
|
+
|
1209
|
+
async def delete_webhook(self, webhook_id: Snowflake) -> None:
|
1210
|
+
"""|coro| Delete a webhook by ID."""
|
1211
|
+
|
1212
|
+
if self._closed:
|
1213
|
+
raise DisagreementException("Client is closed.")
|
1214
|
+
|
1215
|
+
await self._http.delete_webhook(webhook_id)
|
1216
|
+
|
1063
1217
|
# --- Application Command Methods ---
|
1064
1218
|
async def process_interaction(self, interaction: Interaction) -> None:
|
1065
1219
|
"""Internal method to process an interaction from the gateway."""
|
@@ -1142,3 +1296,19 @@ class Client:
|
|
1142
1296
|
|
1143
1297
|
print(f"Unhandled exception in event listener for '{event_method}':")
|
1144
1298
|
print(f"{type(exc).__name__}: {exc}")
|
1299
|
+
|
1300
|
+
|
1301
|
+
class AutoShardedClient(Client):
|
1302
|
+
"""A :class:`Client` that automatically determines the shard count.
|
1303
|
+
|
1304
|
+
If ``shard_count`` is not provided, the client will query the Discord API
|
1305
|
+
via :meth:`HTTPClient.get_gateway_bot` for the recommended shard count and
|
1306
|
+
use that when connecting.
|
1307
|
+
"""
|
1308
|
+
|
1309
|
+
async def connect(self, reconnect: bool = True) -> None: # type: ignore[override]
|
1310
|
+
if self.shard_count is None:
|
1311
|
+
data = await self._http.get_gateway_bot()
|
1312
|
+
self.shard_count = data.get("shards", 1)
|
1313
|
+
|
1314
|
+
await super().connect(reconnect=reconnect)
|
disagreement/color.py
ADDED
@@ -0,0 +1,50 @@
|
|
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)
|
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/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):
|