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 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.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() # 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
-
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)
@@ -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/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):