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.
- 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/__init__.py +0 -0
- disagreement/ext/app_commands/__init__.py +46 -0
- disagreement/ext/app_commands/commands.py +513 -0
- disagreement/ext/app_commands/context.py +556 -0
- disagreement/ext/app_commands/converters.py +478 -0
- disagreement/ext/app_commands/decorators.py +569 -0
- disagreement/ext/app_commands/handler.py +627 -0
- disagreement/ext/commands/__init__.py +57 -0
- disagreement/ext/commands/cog.py +155 -0
- disagreement/ext/commands/converters.py +175 -0
- disagreement/ext/commands/core.py +497 -0
- disagreement/ext/commands/decorators.py +192 -0
- disagreement/ext/commands/errors.py +76 -0
- disagreement/ext/commands/help.py +37 -0
- disagreement/ext/commands/view.py +103 -0
- disagreement/ext/loader.py +54 -0
- disagreement/ext/tasks.py +182 -0
- disagreement/gateway.py +67 -21
- 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.1.dist-info → disagreement-0.1.0rc1.dist-info}/METADATA +47 -33
- disagreement-0.1.0rc1.dist-info/RECORD +52 -0
- disagreement-0.0.1.dist-info/RECORD +0 -32
- {disagreement-0.0.1.dist-info → disagreement-0.1.0rc1.dist-info}/WHEEL +0 -0
- {disagreement-0.0.1.dist-info → disagreement-0.1.0rc1.dist-info}/licenses/LICENSE +0 -0
- {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[
|
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
|
-
|
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
|
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")
|
disagreement/shard_manager.py
CHANGED
@@ -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
disagreement/voice_client.py
CHANGED
@@ -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):
|