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 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.0.2"
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() # e.g., on_message -> MESSAGE
452
- if discord_event_name == "MESSAGE": # Common case
453
- discord_event_name = "MESSAGE_CREATE"
454
- # Add other mappings if needed, e.g. on_member_join -> GUILD_MEMBER_ADD
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 ActionRow as ActionRowModel
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, ActionRowModel)
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")
@@ -18,7 +18,7 @@ from .models import (
18
18
  Thumbnail,
19
19
  MediaGallery,
20
20
  MediaGalleryItem,
21
- File,
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 File(
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
- # You can add more specific exceptions as needed, e.g.:
81
- # class NotFound(HTTPException):
82
- # """Raised for 404 Not Found errors."""
83
- # pass
84
-
85
- # class Forbidden(HTTPException):
86
- # """Raised for 403 Forbidden errors."""
87
- # pass
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):