disagreement 0.0.1__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.
Files changed (36) hide show
  1. disagreement/__init__.py +8 -3
  2. disagreement/audio.py +116 -0
  3. disagreement/client.py +176 -6
  4. disagreement/color.py +50 -0
  5. disagreement/components.py +2 -2
  6. disagreement/errors.py +13 -8
  7. disagreement/event_dispatcher.py +102 -45
  8. disagreement/ext/__init__.py +0 -0
  9. disagreement/ext/app_commands/__init__.py +46 -0
  10. disagreement/ext/app_commands/commands.py +513 -0
  11. disagreement/ext/app_commands/context.py +556 -0
  12. disagreement/ext/app_commands/converters.py +478 -0
  13. disagreement/ext/app_commands/decorators.py +569 -0
  14. disagreement/ext/app_commands/handler.py +627 -0
  15. disagreement/ext/commands/__init__.py +57 -0
  16. disagreement/ext/commands/cog.py +155 -0
  17. disagreement/ext/commands/converters.py +175 -0
  18. disagreement/ext/commands/core.py +497 -0
  19. disagreement/ext/commands/decorators.py +192 -0
  20. disagreement/ext/commands/errors.py +76 -0
  21. disagreement/ext/commands/help.py +37 -0
  22. disagreement/ext/commands/view.py +103 -0
  23. disagreement/ext/loader.py +54 -0
  24. disagreement/ext/tasks.py +182 -0
  25. disagreement/gateway.py +67 -21
  26. disagreement/http.py +104 -3
  27. disagreement/models.py +308 -1
  28. disagreement/shard_manager.py +2 -0
  29. disagreement/utils.py +10 -0
  30. disagreement/voice_client.py +42 -0
  31. {disagreement-0.0.1.dist-info → disagreement-0.1.0rc1.dist-info}/METADATA +47 -33
  32. disagreement-0.1.0rc1.dist-info/RECORD +52 -0
  33. disagreement-0.0.1.dist-info/RECORD +0 -32
  34. {disagreement-0.0.1.dist-info → disagreement-0.1.0rc1.dist-info}/WHEEL +0 -0
  35. {disagreement-0.0.1.dist-info → disagreement-0.1.0rc1.dist-info}/licenses/LICENSE +0 -0
  36. {disagreement-0.0.1.dist-info → disagreement-0.1.0rc1.dist-info}/top_level.txt +0 -0
disagreement/http.py CHANGED
@@ -20,7 +20,7 @@ from . import __version__ # For User-Agent
20
20
 
21
21
  if TYPE_CHECKING:
22
22
  from .client import Client
23
- from .models import Message
23
+ from .models import Message, Webhook, File
24
24
  from .interactions import ApplicationCommand, InteractionResponsePayload, Snowflake
25
25
 
26
26
  # Discord API constants
@@ -60,7 +60,9 @@ class HTTPClient:
60
60
  self,
61
61
  method: str,
62
62
  endpoint: str,
63
- payload: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None,
63
+ payload: Optional[
64
+ Union[Dict[str, Any], List[Dict[str, Any]], aiohttp.FormData]
65
+ ] = None,
64
66
  params: Optional[Dict[str, Any]] = None,
65
67
  is_json: bool = True,
66
68
  use_auth_header: bool = True,
@@ -204,11 +206,23 @@ class HTTPClient:
204
206
  components: Optional[List[Dict[str, Any]]] = None,
205
207
  allowed_mentions: Optional[dict] = None,
206
208
  message_reference: Optional[Dict[str, Any]] = None,
209
+ attachments: Optional[List[Any]] = None,
210
+ files: Optional[List[Any]] = None,
207
211
  flags: Optional[int] = None,
208
212
  ) -> Dict[str, Any]:
209
213
  """Sends a message to a channel.
210
214
 
211
- Returns the created message data as a dict.
215
+ Parameters
216
+ ----------
217
+ attachments:
218
+ A list of attachment payloads to include with the message.
219
+ files:
220
+ A list of :class:`File` objects containing binary data to upload.
221
+
222
+ Returns
223
+ -------
224
+ Dict[str, Any]
225
+ The created message data.
212
226
  """
213
227
  payload: Dict[str, Any] = {}
214
228
  if content is not None: # Content is optional if embeds/components are present
@@ -221,6 +235,28 @@ class HTTPClient:
221
235
  payload["components"] = components
222
236
  if allowed_mentions:
223
237
  payload["allowed_mentions"] = allowed_mentions
238
+ all_files: List["File"] = []
239
+ if attachments is not None:
240
+ payload["attachments"] = []
241
+ for a in attachments:
242
+ if hasattr(a, "data") and hasattr(a, "filename"):
243
+ idx = len(all_files)
244
+ all_files.append(a)
245
+ payload["attachments"].append({"id": idx, "filename": a.filename})
246
+ else:
247
+ payload["attachments"].append(
248
+ a.to_dict() if hasattr(a, "to_dict") else a
249
+ )
250
+ if files is not None:
251
+ for f in files:
252
+ if hasattr(f, "data") and hasattr(f, "filename"):
253
+ idx = len(all_files)
254
+ all_files.append(f)
255
+ if "attachments" not in payload:
256
+ payload["attachments"] = []
257
+ payload["attachments"].append({"id": idx, "filename": f.filename})
258
+ else:
259
+ raise TypeError("files must be File objects")
224
260
  if flags:
225
261
  payload["flags"] = flags
226
262
  if message_reference:
@@ -229,6 +265,25 @@ class HTTPClient:
229
265
  if not payload:
230
266
  raise ValueError("Message must have content, embeds, or components.")
231
267
 
268
+ if all_files:
269
+ form = aiohttp.FormData()
270
+ form.add_field(
271
+ "payload_json", json.dumps(payload), content_type="application/json"
272
+ )
273
+ for idx, f in enumerate(all_files):
274
+ form.add_field(
275
+ f"files[{idx}]",
276
+ f.data,
277
+ filename=f.filename,
278
+ content_type="application/octet-stream",
279
+ )
280
+ return await self.request(
281
+ "POST",
282
+ f"/channels/{channel_id}/messages",
283
+ payload=form,
284
+ is_json=False,
285
+ )
286
+
232
287
  return await self.request(
233
288
  "POST", f"/channels/{channel_id}/messages", payload=payload
234
289
  )
@@ -256,6 +311,13 @@ class HTTPClient:
256
311
  "GET", f"/channels/{channel_id}/messages/{message_id}"
257
312
  )
258
313
 
314
+ async def delete_message(
315
+ self, channel_id: "Snowflake", message_id: "Snowflake"
316
+ ) -> None:
317
+ """Deletes a message in a channel."""
318
+
319
+ await self.request("DELETE", f"/channels/{channel_id}/messages/{message_id}")
320
+
259
321
  async def create_reaction(
260
322
  self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
261
323
  ) -> None:
@@ -286,6 +348,18 @@ class HTTPClient:
286
348
  f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}",
287
349
  )
288
350
 
351
+ async def bulk_delete_messages(
352
+ self, channel_id: "Snowflake", messages: List["Snowflake"]
353
+ ) -> List["Snowflake"]:
354
+ """Bulk deletes messages in a channel and returns their IDs."""
355
+
356
+ await self.request(
357
+ "POST",
358
+ f"/channels/{channel_id}/messages/bulk-delete",
359
+ payload={"messages": messages},
360
+ )
361
+ return messages
362
+
289
363
  async def delete_channel(
290
364
  self, channel_id: str, reason: Optional[str] = None
291
365
  ) -> None:
@@ -310,6 +384,33 @@ class HTTPClient:
310
384
  """Fetches a channel by ID."""
311
385
  return await self.request("GET", f"/channels/{channel_id}")
312
386
 
387
+ async def create_webhook(
388
+ self, channel_id: "Snowflake", payload: Dict[str, Any]
389
+ ) -> "Webhook":
390
+ """Creates a webhook in the specified channel."""
391
+
392
+ data = await self.request(
393
+ "POST", f"/channels/{channel_id}/webhooks", payload=payload
394
+ )
395
+ from .models import Webhook
396
+
397
+ return Webhook(data)
398
+
399
+ async def edit_webhook(
400
+ self, webhook_id: "Snowflake", payload: Dict[str, Any]
401
+ ) -> "Webhook":
402
+ """Edits an existing webhook."""
403
+
404
+ data = await self.request("PATCH", f"/webhooks/{webhook_id}", payload=payload)
405
+ from .models import Webhook
406
+
407
+ return Webhook(data)
408
+
409
+ async def delete_webhook(self, webhook_id: "Snowflake") -> None:
410
+ """Deletes a webhook."""
411
+
412
+ await self.request("DELETE", f"/webhooks/{webhook_id}")
413
+
313
414
  async def get_user(self, user_id: "Snowflake") -> Dict[str, Any]:
314
415
  """Fetches a user object for a given user ID."""
315
416
  return await self.request("GET", f"/users/{user_id}")
disagreement/models.py CHANGED
@@ -5,6 +5,9 @@ Data models for Discord objects.
5
5
  """
6
6
 
7
7
  import json
8
+ import asyncio
9
+ import aiohttp # pylint: disable=import-error
10
+ import asyncio
8
11
  from typing import Optional, TYPE_CHECKING, List, Dict, Any, Union
9
12
 
10
13
  from .errors import DisagreementException, HTTPException
@@ -21,12 +24,14 @@ from .enums import ( # These enums will need to be defined in disagreement/enum
21
24
  ButtonStyle, # Added for Button
22
25
  # SelectMenuType will be part of ComponentType or a new enum if needed
23
26
  )
27
+ from .permissions import Permissions
24
28
 
25
29
 
26
30
  if TYPE_CHECKING:
27
31
  from .client import Client # For type hinting to avoid circular imports
28
32
  from .enums import OverwriteType # For PermissionOverwrite model
29
33
  from .ui.view import View
34
+ from .interactions import Snowflake
30
35
 
31
36
  # Forward reference Message if it were used in type hints before its definition
32
37
  # from .models import Message # Not needed as Message is defined before its use in TextChannel.send etc.
@@ -72,6 +77,7 @@ class Message:
72
77
  timestamp (str): When this message was sent (ISO8601 timestamp).
73
78
  components (Optional[List[ActionRow]]): Structured components attached
74
79
  to the message if present.
80
+ attachments (List[Attachment]): Attachments included with the message.
75
81
  """
76
82
 
77
83
  def __init__(self, data: dict, client_instance: "Client"):
@@ -92,6 +98,9 @@ class Message:
92
98
  ]
93
99
  else:
94
100
  self.components = None
101
+ self.attachments: List[Attachment] = [
102
+ Attachment(a) for a in data.get("attachments", [])
103
+ ]
95
104
  # Add other fields as needed, e.g., attachments, embeds, reactions, etc.
96
105
  # self.mentions: List[User] = [User(u) for u in data.get("mentions", [])]
97
106
  # self.mention_roles: List[str] = data.get("mention_roles", [])
@@ -192,6 +201,22 @@ class Message:
192
201
  view=view,
193
202
  )
194
203
 
204
+ async def delete(self, delay: Optional[float] = None) -> None:
205
+ """|coro|
206
+
207
+ Deletes this message.
208
+
209
+ Parameters
210
+ ----------
211
+ delay:
212
+ If provided, wait this many seconds before deleting.
213
+ """
214
+
215
+ if delay is not None:
216
+ await asyncio.sleep(delay)
217
+
218
+ await self._client._http.delete_message(self.channel_id, self.id)
219
+
195
220
  def __repr__(self) -> str:
196
221
  return f"<Message id='{self.id}' channel_id='{self.channel_id}' author='{self.author!r}'>"
197
222
 
@@ -373,6 +398,14 @@ class Attachment:
373
398
  return payload
374
399
 
375
400
 
401
+ class File:
402
+ """Represents a file to be uploaded."""
403
+
404
+ def __init__(self, filename: str, data: bytes):
405
+ self.filename = filename
406
+ self.data = data
407
+
408
+
376
409
  class AllowedMentions:
377
410
  """Represents allowed mentions for a message or interaction response."""
378
411
 
@@ -527,6 +560,34 @@ class Member(User): # Member inherits from User
527
560
  reason=reason,
528
561
  )
529
562
 
563
+ @property
564
+ def top_role(self) -> Optional["Role"]:
565
+ """Return the member's highest role from the guild cache."""
566
+
567
+ if not self.guild_id or not self._client:
568
+ return None
569
+
570
+ guild = self._client.get_guild(self.guild_id)
571
+ if not guild:
572
+ return None
573
+
574
+ if not guild.roles and hasattr(self._client, "fetch_roles"):
575
+ try:
576
+ self._client.loop.run_until_complete(
577
+ self._client.fetch_roles(self.guild_id)
578
+ )
579
+ except RuntimeError:
580
+ future = asyncio.run_coroutine_threadsafe(
581
+ self._client.fetch_roles(self.guild_id), self._client.loop
582
+ )
583
+ future.result()
584
+
585
+ role_objects = [r for r in guild.roles if r.id in self.roles]
586
+ if not role_objects:
587
+ return None
588
+
589
+ return max(role_objects, key=lambda r: r.position)
590
+
530
591
 
531
592
  class PartialEmoji:
532
593
  """Represents a partial emoji, often used in components or reactions.
@@ -853,6 +914,31 @@ class Guild:
853
914
  def get_member(self, user_id: str) -> Optional[Member]:
854
915
  return self._members.get(user_id)
855
916
 
917
+ def get_member_named(self, name: str) -> Optional[Member]:
918
+ """Retrieve a cached member by username or nickname.
919
+
920
+ The lookup is case-insensitive and searches both the username and
921
+ guild nickname for a match.
922
+
923
+ Parameters
924
+ ----------
925
+ name: str
926
+ The username or nickname to search for.
927
+
928
+ Returns
929
+ -------
930
+ Optional[Member]
931
+ The matching member if found, otherwise ``None``.
932
+ """
933
+
934
+ lowered = name.lower()
935
+ for member in self._members.values():
936
+ if member.username.lower() == lowered:
937
+ return member
938
+ if member.nick and member.nick.lower() == lowered:
939
+ return member
940
+ return None
941
+
856
942
  def get_role(self, role_id: str) -> Optional[Role]:
857
943
  return next((role for role in self.roles if role.id == role_id), None)
858
944
 
@@ -893,6 +979,78 @@ class Channel:
893
979
  def __repr__(self) -> str:
894
980
  return f"<Channel id='{self.id}' name='{self.name}' type='{self.type.name if hasattr(self.type, 'name') else self._type_val}'>"
895
981
 
982
+ def permission_overwrite_for(
983
+ self, target: Union["Role", "Member", str]
984
+ ) -> Optional["PermissionOverwrite"]:
985
+ """Return the :class:`PermissionOverwrite` for ``target`` if present."""
986
+
987
+ if isinstance(target, str):
988
+ target_id = target
989
+ else:
990
+ target_id = target.id
991
+ for overwrite in self.permission_overwrites:
992
+ if overwrite.id == target_id:
993
+ return overwrite
994
+ return None
995
+
996
+ @staticmethod
997
+ def _apply_overwrite(
998
+ perms: Permissions, overwrite: Optional["PermissionOverwrite"]
999
+ ) -> Permissions:
1000
+ if overwrite is None:
1001
+ return perms
1002
+
1003
+ perms &= ~Permissions(int(overwrite.deny))
1004
+ perms |= Permissions(int(overwrite.allow))
1005
+ return perms
1006
+
1007
+ def permissions_for(self, member: "Member") -> Permissions:
1008
+ """Resolve channel permissions for ``member``."""
1009
+
1010
+ if self.guild_id is None:
1011
+ return Permissions(~0)
1012
+
1013
+ if not hasattr(self._client, "get_guild"):
1014
+ return Permissions(0)
1015
+
1016
+ guild = self._client.get_guild(self.guild_id)
1017
+ if guild is None:
1018
+ return Permissions(0)
1019
+
1020
+ base = Permissions(0)
1021
+
1022
+ everyone = guild.get_role(guild.id)
1023
+ if everyone is not None:
1024
+ base |= Permissions(int(everyone.permissions))
1025
+
1026
+ for rid in member.roles:
1027
+ role = guild.get_role(rid)
1028
+ if role is not None:
1029
+ base |= Permissions(int(role.permissions))
1030
+
1031
+ if base & Permissions.ADMINISTRATOR:
1032
+ return Permissions(~0)
1033
+
1034
+ # Apply @everyone overwrite
1035
+ base = self._apply_overwrite(base, self.permission_overwrite_for(guild.id))
1036
+
1037
+ # Role overwrites
1038
+ role_allow = Permissions(0)
1039
+ role_deny = Permissions(0)
1040
+ for rid in member.roles:
1041
+ ow = self.permission_overwrite_for(rid)
1042
+ if ow is not None:
1043
+ role_allow |= Permissions(int(ow.allow))
1044
+ role_deny |= Permissions(int(ow.deny))
1045
+
1046
+ base &= ~role_deny
1047
+ base |= role_allow
1048
+
1049
+ # Member overwrite
1050
+ base = self._apply_overwrite(base, self.permission_overwrite_for(member.id))
1051
+
1052
+ return base
1053
+
896
1054
 
897
1055
  class TextChannel(Channel):
898
1056
  """Represents a guild text channel or announcement channel."""
@@ -928,6 +1086,27 @@ class TextChannel(Channel):
928
1086
  components=components,
929
1087
  )
930
1088
 
1089
+ async def purge(
1090
+ self, limit: int, *, before: "Snowflake | None" = None
1091
+ ) -> List["Snowflake"]:
1092
+ """Bulk delete messages from this channel."""
1093
+
1094
+ params: Dict[str, Union[int, str]] = {"limit": limit}
1095
+ if before is not None:
1096
+ params["before"] = before
1097
+
1098
+ messages = await self._client._http.request(
1099
+ "GET", f"/channels/{self.id}/messages", params=params
1100
+ )
1101
+ ids = [m["id"] for m in messages]
1102
+ if not ids:
1103
+ return []
1104
+
1105
+ await self._client._http.bulk_delete_messages(self.id, ids)
1106
+ for mid in ids:
1107
+ self._client._messages.pop(mid, None)
1108
+ return ids
1109
+
931
1110
  def __repr__(self) -> str:
932
1111
  return f"<TextChannel id='{self.id}' name='{self.name}' guild_id='{self.guild_id}'>"
933
1112
 
@@ -1090,6 +1269,55 @@ class PartialChannel:
1090
1269
  return f"<PartialChannel id='{self.id}' name='{self.name}' type='{type_name}'>"
1091
1270
 
1092
1271
 
1272
+ class Webhook:
1273
+ """Represents a Discord Webhook."""
1274
+
1275
+ def __init__(
1276
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1277
+ ):
1278
+ self._client: Optional["Client"] = client_instance
1279
+ self.id: str = data["id"]
1280
+ self.type: int = int(data.get("type", 1))
1281
+ self.guild_id: Optional[str] = data.get("guild_id")
1282
+ self.channel_id: Optional[str] = data.get("channel_id")
1283
+ self.name: Optional[str] = data.get("name")
1284
+ self.avatar: Optional[str] = data.get("avatar")
1285
+ self.token: Optional[str] = data.get("token")
1286
+ self.application_id: Optional[str] = data.get("application_id")
1287
+ self.url: Optional[str] = data.get("url")
1288
+ self.user: Optional[User] = User(data["user"]) if data.get("user") else None
1289
+
1290
+ def __repr__(self) -> str:
1291
+ return f"<Webhook id='{self.id}' name='{self.name}'>"
1292
+
1293
+ @classmethod
1294
+ def from_url(
1295
+ cls, url: str, session: Optional[aiohttp.ClientSession] = None
1296
+ ) -> "Webhook":
1297
+ """Create a minimal :class:`Webhook` from a webhook URL.
1298
+
1299
+ Parameters
1300
+ ----------
1301
+ url:
1302
+ The full Discord webhook URL.
1303
+ session:
1304
+ Unused for now. Present for API compatibility.
1305
+
1306
+ Returns
1307
+ -------
1308
+ Webhook
1309
+ A webhook instance containing only the ``id``, ``token`` and ``url``.
1310
+ """
1311
+
1312
+ parts = url.rstrip("/").split("/")
1313
+ if len(parts) < 2:
1314
+ raise ValueError("Invalid webhook URL")
1315
+ token = parts[-1]
1316
+ webhook_id = parts[-2]
1317
+
1318
+ return cls({"id": webhook_id, "token": token, "url": url})
1319
+
1320
+
1093
1321
  # --- Message Components ---
1094
1322
 
1095
1323
 
@@ -1433,7 +1661,7 @@ class MediaGallery(Component):
1433
1661
  return payload
1434
1662
 
1435
1663
 
1436
- class File(Component):
1664
+ class FileComponent(Component):
1437
1665
  """Represents a file component."""
1438
1666
 
1439
1667
  def __init__(
@@ -1614,6 +1842,85 @@ class TypingStart:
1614
1842
  return f"<TypingStart channel_id='{self.channel_id}' user_id='{self.user_id}'>"
1615
1843
 
1616
1844
 
1845
+ class Reaction:
1846
+ """Represents a message reaction event."""
1847
+
1848
+ def __init__(
1849
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1850
+ ):
1851
+ self._client = client_instance
1852
+ self.user_id: str = data["user_id"]
1853
+ self.channel_id: str = data["channel_id"]
1854
+ self.message_id: str = data["message_id"]
1855
+ self.guild_id: Optional[str] = data.get("guild_id")
1856
+ self.member: Optional[Member] = (
1857
+ Member(data["member"], client_instance) if data.get("member") else None
1858
+ )
1859
+ self.emoji: Dict[str, Any] = data.get("emoji", {})
1860
+
1861
+ def __repr__(self) -> str:
1862
+ emoji_value = self.emoji.get("name") or self.emoji.get("id")
1863
+ return f"<Reaction message_id='{self.message_id}' user_id='{self.user_id}' emoji='{emoji_value}'>"
1864
+
1865
+
1866
+ class GuildMemberRemove:
1867
+ """Represents a GUILD_MEMBER_REMOVE event."""
1868
+
1869
+ def __init__(
1870
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1871
+ ):
1872
+ self._client = client_instance
1873
+ self.guild_id: str = data["guild_id"]
1874
+ self.user: User = User(data["user"])
1875
+
1876
+ def __repr__(self) -> str:
1877
+ return (
1878
+ f"<GuildMemberRemove guild_id='{self.guild_id}' user_id='{self.user.id}'>"
1879
+ )
1880
+
1881
+
1882
+ class GuildBanAdd:
1883
+ """Represents a GUILD_BAN_ADD event."""
1884
+
1885
+ def __init__(
1886
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1887
+ ):
1888
+ self._client = client_instance
1889
+ self.guild_id: str = data["guild_id"]
1890
+ self.user: User = User(data["user"])
1891
+
1892
+ def __repr__(self) -> str:
1893
+ return f"<GuildBanAdd guild_id='{self.guild_id}' user_id='{self.user.id}'>"
1894
+
1895
+
1896
+ class GuildBanRemove:
1897
+ """Represents a GUILD_BAN_REMOVE event."""
1898
+
1899
+ def __init__(
1900
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1901
+ ):
1902
+ self._client = client_instance
1903
+ self.guild_id: str = data["guild_id"]
1904
+ self.user: User = User(data["user"])
1905
+
1906
+ def __repr__(self) -> str:
1907
+ return f"<GuildBanRemove guild_id='{self.guild_id}' user_id='{self.user.id}'>"
1908
+
1909
+
1910
+ class GuildRoleUpdate:
1911
+ """Represents a GUILD_ROLE_UPDATE event."""
1912
+
1913
+ def __init__(
1914
+ self, data: Dict[str, Any], client_instance: Optional["Client"] = None
1915
+ ):
1916
+ self._client = client_instance
1917
+ self.guild_id: str = data["guild_id"]
1918
+ self.role: Role = Role(data["role"])
1919
+
1920
+ def __repr__(self) -> str:
1921
+ return f"<GuildRoleUpdate guild_id='{self.guild_id}' role_id='{self.role.id}'>"
1922
+
1923
+
1617
1924
  def channel_factory(data: Dict[str, Any], client: "Client") -> Channel:
1618
1925
  """Create a channel object from raw API data."""
1619
1926
  channel_type = data.get("type")
@@ -51,6 +51,8 @@ class ShardManager:
51
51
  verbose=self.client.verbose,
52
52
  shard_id=shard_id,
53
53
  shard_count=self.shard_count,
54
+ max_retries=self.client.gateway_max_retries,
55
+ max_backoff=self.client.gateway_max_backoff,
54
56
  )
55
57
  self.shards.append(Shard(shard_id, self.shard_count, gateway))
56
58
 
disagreement/utils.py ADDED
@@ -0,0 +1,10 @@
1
+ """Utility helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+
8
+ def utcnow() -> datetime:
9
+ """Return the current timezone-aware UTC time."""
10
+ return datetime.now(timezone.utc)
@@ -10,6 +10,8 @@ from typing import Optional, Sequence
10
10
 
11
11
  import aiohttp
12
12
 
13
+ from .audio import AudioSource, FFmpegAudioSource
14
+
13
15
 
14
16
  class VoiceClient:
15
17
  """Handles the Discord voice WebSocket connection and UDP streaming."""
@@ -43,6 +45,8 @@ class VoiceClient:
43
45
  self.secret_key: Optional[Sequence[int]] = None
44
46
  self._server_ip: Optional[str] = None
45
47
  self._server_port: Optional[int] = None
48
+ self._current_source: Optional[AudioSource] = None
49
+ self._play_task: Optional[asyncio.Task] = None
46
50
 
47
51
  async def connect(self) -> None:
48
52
  if self._ws is None:
@@ -107,7 +111,45 @@ class VoiceClient:
107
111
  raise RuntimeError("UDP socket not initialised")
108
112
  self._udp.send(frame)
109
113
 
114
+ async def _play_loop(self) -> None:
115
+ assert self._current_source is not None
116
+ try:
117
+ while True:
118
+ data = await self._current_source.read()
119
+ if not data:
120
+ break
121
+ await self.send_audio_frame(data)
122
+ finally:
123
+ await self._current_source.close()
124
+ self._current_source = None
125
+ self._play_task = None
126
+
127
+ async def stop(self) -> None:
128
+ if self._play_task:
129
+ self._play_task.cancel()
130
+ with contextlib.suppress(asyncio.CancelledError):
131
+ await self._play_task
132
+ self._play_task = None
133
+ if self._current_source:
134
+ await self._current_source.close()
135
+ self._current_source = None
136
+
137
+ async def play(self, source: AudioSource, *, wait: bool = True) -> None:
138
+ """|coro| Play an :class:`AudioSource` on the voice connection."""
139
+
140
+ await self.stop()
141
+ self._current_source = source
142
+ self._play_task = self._loop.create_task(self._play_loop())
143
+ if wait:
144
+ await self._play_task
145
+
146
+ async def play_file(self, filename: str, *, wait: bool = True) -> None:
147
+ """|coro| Stream an audio file or URL using FFmpeg."""
148
+
149
+ await self.play(FFmpegAudioSource(filename), wait=wait)
150
+
110
151
  async def close(self) -> None:
152
+ await self.stop()
111
153
  if self._heartbeat_task:
112
154
  self._heartbeat_task.cancel()
113
155
  with contextlib.suppress(asyncio.CancelledError):