disagreement 0.1.0rc3__py3-none-any.whl → 0.2.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 +2 -2
- disagreement/client.py +198 -2
- disagreement/color.py +100 -1
- disagreement/enums.py +63 -0
- disagreement/ext/app_commands/commands.py +0 -2
- disagreement/ext/app_commands/handler.py +101 -30
- disagreement/ext/commands/__init__.py +4 -0
- disagreement/ext/commands/core.py +51 -1
- disagreement/ext/commands/decorators.py +27 -0
- disagreement/ext/commands/errors.py +8 -0
- disagreement/gateway.py +11 -0
- disagreement/http.py +172 -6
- disagreement/models.py +203 -4
- {disagreement-0.1.0rc3.dist-info → disagreement-0.2.0rc1.dist-info}/METADATA +17 -1
- {disagreement-0.1.0rc3.dist-info → disagreement-0.2.0rc1.dist-info}/RECORD +18 -18
- {disagreement-0.1.0rc3.dist-info → disagreement-0.2.0rc1.dist-info}/WHEEL +0 -0
- {disagreement-0.1.0rc3.dist-info → disagreement-0.2.0rc1.dist-info}/licenses/LICENSE +0 -0
- {disagreement-0.1.0rc3.dist-info → disagreement-0.2.0rc1.dist-info}/top_level.txt +0 -0
disagreement/__init__.py
CHANGED
@@ -14,10 +14,10 @@ __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.2.0rc1"
|
18
18
|
|
19
19
|
from .client import Client, AutoShardedClient
|
20
|
-
from .models import Message, User, Reaction
|
20
|
+
from .models import Message, User, Reaction, AuditLogEntry
|
21
21
|
from .voice_client import VoiceClient
|
22
22
|
from .audio import AudioSource, FFmpegAudioSource
|
23
23
|
from .typing import Typing
|
disagreement/client.py
CHANGED
@@ -12,6 +12,7 @@ from typing import (
|
|
12
12
|
Any,
|
13
13
|
TYPE_CHECKING,
|
14
14
|
Awaitable,
|
15
|
+
AsyncIterator,
|
15
16
|
Union,
|
16
17
|
List,
|
17
18
|
Dict,
|
@@ -22,7 +23,7 @@ from .http import HTTPClient
|
|
22
23
|
from .gateway import GatewayClient
|
23
24
|
from .shard_manager import ShardManager
|
24
25
|
from .event_dispatcher import EventDispatcher
|
25
|
-
from .enums import GatewayIntent, InteractionType, GatewayOpcode
|
26
|
+
from .enums import GatewayIntent, InteractionType, GatewayOpcode, VoiceRegion
|
26
27
|
from .errors import DisagreementException, AuthenticationError
|
27
28
|
from .typing import Typing
|
28
29
|
from .ext.commands.core import CommandHandler
|
@@ -50,6 +51,10 @@ if TYPE_CHECKING:
|
|
50
51
|
Thread,
|
51
52
|
DMChannel,
|
52
53
|
Webhook,
|
54
|
+
GuildTemplate,
|
55
|
+
ScheduledEvent,
|
56
|
+
AuditLogEntry,
|
57
|
+
Invite,
|
53
58
|
)
|
54
59
|
from .ui.view import View
|
55
60
|
from .enums import ChannelType as EnumChannelType
|
@@ -72,6 +77,9 @@ class Client:
|
|
72
77
|
command_prefix (Union[str, List[str], Callable[['Client', Message], Union[str, List[str]]]]):
|
73
78
|
The prefix(es) for commands. Defaults to '!'.
|
74
79
|
verbose (bool): If True, print raw HTTP and Gateway traffic for debugging.
|
80
|
+
http_options (Optional[Dict[str, Any]]): Extra options passed to
|
81
|
+
:class:`HTTPClient` for creating the internal
|
82
|
+
:class:`aiohttp.ClientSession`.
|
75
83
|
"""
|
76
84
|
|
77
85
|
def __init__(
|
@@ -88,6 +96,7 @@ class Client:
|
|
88
96
|
shard_count: Optional[int] = None,
|
89
97
|
gateway_max_retries: int = 5,
|
90
98
|
gateway_max_backoff: float = 60.0,
|
99
|
+
http_options: Optional[Dict[str, Any]] = None,
|
91
100
|
):
|
92
101
|
if not token:
|
93
102
|
raise ValueError("A bot token must be provided.")
|
@@ -101,7 +110,11 @@ class Client:
|
|
101
110
|
setup_global_error_handler(self.loop)
|
102
111
|
|
103
112
|
self.verbose: bool = verbose
|
104
|
-
self._http: HTTPClient = HTTPClient(
|
113
|
+
self._http: HTTPClient = HTTPClient(
|
114
|
+
token=self.token,
|
115
|
+
verbose=verbose,
|
116
|
+
**(http_options or {}),
|
117
|
+
)
|
105
118
|
self._event_dispatcher: EventDispatcher = EventDispatcher(client_instance=self)
|
106
119
|
self._gateway: Optional[GatewayClient] = (
|
107
120
|
None # Initialized in run() or connect()
|
@@ -698,6 +711,40 @@ class Client:
|
|
698
711
|
self._webhooks[webhook.id] = webhook
|
699
712
|
return webhook
|
700
713
|
|
714
|
+
def parse_template(self, data: Dict[str, Any]) -> "GuildTemplate":
|
715
|
+
"""Parses template data into a GuildTemplate object."""
|
716
|
+
|
717
|
+
from .models import GuildTemplate
|
718
|
+
|
719
|
+
return GuildTemplate(data, client_instance=self)
|
720
|
+
|
721
|
+
def parse_scheduled_event(self, data: Dict[str, Any]) -> "ScheduledEvent":
|
722
|
+
"""Parses scheduled event data and updates cache."""
|
723
|
+
|
724
|
+
from .models import ScheduledEvent
|
725
|
+
|
726
|
+
event = ScheduledEvent(data, client_instance=self)
|
727
|
+
# Cache by ID under guild if guild cache exists
|
728
|
+
guild = self._guilds.get(event.guild_id)
|
729
|
+
if guild is not None:
|
730
|
+
events = getattr(guild, "_scheduled_events", {})
|
731
|
+
events[event.id] = event
|
732
|
+
setattr(guild, "_scheduled_events", events)
|
733
|
+
return event
|
734
|
+
|
735
|
+
def parse_audit_log_entry(self, data: Dict[str, Any]) -> "AuditLogEntry":
|
736
|
+
"""Parses audit log entry data."""
|
737
|
+
from .models import AuditLogEntry
|
738
|
+
|
739
|
+
return AuditLogEntry(data, client_instance=self)
|
740
|
+
|
741
|
+
def parse_invite(self, data: Dict[str, Any]) -> "Invite":
|
742
|
+
"""Parses invite data into an :class:`Invite`."""
|
743
|
+
|
744
|
+
from .models import Invite
|
745
|
+
|
746
|
+
return Invite.from_dict(data)
|
747
|
+
|
701
748
|
async def fetch_user(self, user_id: Snowflake) -> Optional["User"]:
|
702
749
|
"""Fetches a user by ID from Discord."""
|
703
750
|
if self._closed:
|
@@ -1219,6 +1266,31 @@ class Client:
|
|
1219
1266
|
print(f"Failed to fetch channel {channel_id}: {e}")
|
1220
1267
|
return None
|
1221
1268
|
|
1269
|
+
async def fetch_audit_logs(
|
1270
|
+
self, guild_id: Snowflake, **filters: Any
|
1271
|
+
) -> AsyncIterator["AuditLogEntry"]:
|
1272
|
+
"""Fetch audit log entries for a guild."""
|
1273
|
+
if self._closed:
|
1274
|
+
raise DisagreementException("Client is closed.")
|
1275
|
+
|
1276
|
+
data = await self._http.get_audit_logs(guild_id, **filters)
|
1277
|
+
for entry in data.get("audit_log_entries", []):
|
1278
|
+
yield self.parse_audit_log_entry(entry)
|
1279
|
+
|
1280
|
+
async def fetch_voice_regions(self) -> List[VoiceRegion]:
|
1281
|
+
"""Fetches available voice regions."""
|
1282
|
+
|
1283
|
+
if self._closed:
|
1284
|
+
raise DisagreementException("Client is closed.")
|
1285
|
+
|
1286
|
+
data = await self._http.get_voice_regions()
|
1287
|
+
regions = []
|
1288
|
+
for region in data:
|
1289
|
+
region_id = region.get("id")
|
1290
|
+
if region_id:
|
1291
|
+
regions.append(VoiceRegion(region_id))
|
1292
|
+
return regions
|
1293
|
+
|
1222
1294
|
async def create_webhook(
|
1223
1295
|
self, channel_id: Snowflake, payload: Dict[str, Any]
|
1224
1296
|
) -> "Webhook":
|
@@ -1249,6 +1321,130 @@ class Client:
|
|
1249
1321
|
|
1250
1322
|
await self._http.delete_webhook(webhook_id)
|
1251
1323
|
|
1324
|
+
async def fetch_templates(self, guild_id: Snowflake) -> List["GuildTemplate"]:
|
1325
|
+
"""|coro| Fetch all templates for a guild."""
|
1326
|
+
|
1327
|
+
if self._closed:
|
1328
|
+
raise DisagreementException("Client is closed.")
|
1329
|
+
|
1330
|
+
data = await self._http.get_guild_templates(guild_id)
|
1331
|
+
return [self.parse_template(t) for t in data]
|
1332
|
+
|
1333
|
+
async def create_template(
|
1334
|
+
self, guild_id: Snowflake, payload: Dict[str, Any]
|
1335
|
+
) -> "GuildTemplate":
|
1336
|
+
"""|coro| Create a template for a guild."""
|
1337
|
+
|
1338
|
+
if self._closed:
|
1339
|
+
raise DisagreementException("Client is closed.")
|
1340
|
+
|
1341
|
+
data = await self._http.create_guild_template(guild_id, payload)
|
1342
|
+
return self.parse_template(data)
|
1343
|
+
|
1344
|
+
async def sync_template(
|
1345
|
+
self, guild_id: Snowflake, template_code: str
|
1346
|
+
) -> "GuildTemplate":
|
1347
|
+
"""|coro| Sync a template to the guild's current state."""
|
1348
|
+
|
1349
|
+
if self._closed:
|
1350
|
+
raise DisagreementException("Client is closed.")
|
1351
|
+
|
1352
|
+
data = await self._http.sync_guild_template(guild_id, template_code)
|
1353
|
+
return self.parse_template(data)
|
1354
|
+
|
1355
|
+
async def delete_template(self, guild_id: Snowflake, template_code: str) -> None:
|
1356
|
+
"""|coro| Delete a guild template."""
|
1357
|
+
|
1358
|
+
if self._closed:
|
1359
|
+
raise DisagreementException("Client is closed.")
|
1360
|
+
|
1361
|
+
await self._http.delete_guild_template(guild_id, template_code)
|
1362
|
+
|
1363
|
+
async def fetch_scheduled_events(
|
1364
|
+
self, guild_id: Snowflake
|
1365
|
+
) -> List["ScheduledEvent"]:
|
1366
|
+
"""|coro| Fetch all scheduled events for a guild."""
|
1367
|
+
|
1368
|
+
if self._closed:
|
1369
|
+
raise DisagreementException("Client is closed.")
|
1370
|
+
|
1371
|
+
data = await self._http.get_guild_scheduled_events(guild_id)
|
1372
|
+
return [self.parse_scheduled_event(ev) for ev in data]
|
1373
|
+
|
1374
|
+
async def fetch_scheduled_event(
|
1375
|
+
self, guild_id: Snowflake, event_id: Snowflake
|
1376
|
+
) -> Optional["ScheduledEvent"]:
|
1377
|
+
"""|coro| Fetch a single scheduled event."""
|
1378
|
+
|
1379
|
+
if self._closed:
|
1380
|
+
raise DisagreementException("Client is closed.")
|
1381
|
+
|
1382
|
+
try:
|
1383
|
+
data = await self._http.get_guild_scheduled_event(guild_id, event_id)
|
1384
|
+
return self.parse_scheduled_event(data)
|
1385
|
+
except DisagreementException as e:
|
1386
|
+
print(f"Failed to fetch scheduled event {event_id}: {e}")
|
1387
|
+
return None
|
1388
|
+
|
1389
|
+
async def create_scheduled_event(
|
1390
|
+
self, guild_id: Snowflake, payload: Dict[str, Any]
|
1391
|
+
) -> "ScheduledEvent":
|
1392
|
+
"""|coro| Create a scheduled event in a guild."""
|
1393
|
+
|
1394
|
+
if self._closed:
|
1395
|
+
raise DisagreementException("Client is closed.")
|
1396
|
+
|
1397
|
+
data = await self._http.create_guild_scheduled_event(guild_id, payload)
|
1398
|
+
return self.parse_scheduled_event(data)
|
1399
|
+
|
1400
|
+
async def edit_scheduled_event(
|
1401
|
+
self, guild_id: Snowflake, event_id: Snowflake, payload: Dict[str, Any]
|
1402
|
+
) -> "ScheduledEvent":
|
1403
|
+
"""|coro| Edit an existing scheduled event."""
|
1404
|
+
|
1405
|
+
if self._closed:
|
1406
|
+
raise DisagreementException("Client is closed.")
|
1407
|
+
|
1408
|
+
data = await self._http.edit_guild_scheduled_event(guild_id, event_id, payload)
|
1409
|
+
return self.parse_scheduled_event(data)
|
1410
|
+
|
1411
|
+
async def delete_scheduled_event(
|
1412
|
+
self, guild_id: Snowflake, event_id: Snowflake
|
1413
|
+
) -> None:
|
1414
|
+
"""|coro| Delete a scheduled event."""
|
1415
|
+
|
1416
|
+
if self._closed:
|
1417
|
+
raise DisagreementException("Client is closed.")
|
1418
|
+
|
1419
|
+
await self._http.delete_guild_scheduled_event(guild_id, event_id)
|
1420
|
+
|
1421
|
+
async def create_invite(
|
1422
|
+
self, channel_id: Snowflake, payload: Dict[str, Any]
|
1423
|
+
) -> "Invite":
|
1424
|
+
"""|coro| Create an invite for the given channel."""
|
1425
|
+
|
1426
|
+
if self._closed:
|
1427
|
+
raise DisagreementException("Client is closed.")
|
1428
|
+
|
1429
|
+
return await self._http.create_invite(channel_id, payload)
|
1430
|
+
|
1431
|
+
async def delete_invite(self, code: str) -> None:
|
1432
|
+
"""|coro| Delete an invite by code."""
|
1433
|
+
|
1434
|
+
if self._closed:
|
1435
|
+
raise DisagreementException("Client is closed.")
|
1436
|
+
|
1437
|
+
await self._http.delete_invite(code)
|
1438
|
+
|
1439
|
+
async def fetch_invites(self, channel_id: Snowflake) -> List["Invite"]:
|
1440
|
+
"""|coro| Fetch all invites for a channel."""
|
1441
|
+
|
1442
|
+
if self._closed:
|
1443
|
+
raise DisagreementException("Client is closed.")
|
1444
|
+
|
1445
|
+
data = await self._http.get_channel_invites(channel_id)
|
1446
|
+
return [self.parse_invite(inv) for inv in data]
|
1447
|
+
|
1252
1448
|
# --- Application Command Methods ---
|
1253
1449
|
async def process_interaction(self, interaction: Interaction) -> None:
|
1254
1450
|
"""Internal method to process an interaction from the gateway."""
|
disagreement/color.py
CHANGED
@@ -46,11 +46,110 @@ class Color:
|
|
46
46
|
def blue(cls) -> "Color":
|
47
47
|
return cls(0x0000FF)
|
48
48
|
|
49
|
+
# Discord brand colors
|
50
|
+
@classmethod
|
51
|
+
def blurple(cls) -> "Color":
|
52
|
+
"""Discord brand blurple (#5865F2)."""
|
53
|
+
return cls(0x5865F2)
|
54
|
+
|
55
|
+
@classmethod
|
56
|
+
def light_blurple(cls) -> "Color":
|
57
|
+
"""Light blurple used by Discord (#E0E3FF)."""
|
58
|
+
return cls(0xE0E3FF)
|
59
|
+
|
60
|
+
@classmethod
|
61
|
+
def legacy_blurple(cls) -> "Color":
|
62
|
+
"""Legacy Discord blurple (#7289DA)."""
|
63
|
+
return cls(0x7289DA)
|
64
|
+
|
65
|
+
# Additional assorted colors
|
66
|
+
@classmethod
|
67
|
+
def teal(cls) -> "Color":
|
68
|
+
return cls(0x1ABC9C)
|
69
|
+
|
70
|
+
@classmethod
|
71
|
+
def dark_teal(cls) -> "Color":
|
72
|
+
return cls(0x11806A)
|
73
|
+
|
74
|
+
@classmethod
|
75
|
+
def brand_green(cls) -> "Color":
|
76
|
+
return cls(0x57F287)
|
77
|
+
|
78
|
+
@classmethod
|
79
|
+
def dark_green(cls) -> "Color":
|
80
|
+
return cls(0x206694)
|
81
|
+
|
82
|
+
@classmethod
|
83
|
+
def orange(cls) -> "Color":
|
84
|
+
return cls(0xE67E22)
|
85
|
+
|
86
|
+
@classmethod
|
87
|
+
def dark_orange(cls) -> "Color":
|
88
|
+
return cls(0xA84300)
|
89
|
+
|
90
|
+
@classmethod
|
91
|
+
def brand_red(cls) -> "Color":
|
92
|
+
return cls(0xED4245)
|
93
|
+
|
94
|
+
@classmethod
|
95
|
+
def dark_red(cls) -> "Color":
|
96
|
+
return cls(0x992D22)
|
97
|
+
|
98
|
+
@classmethod
|
99
|
+
def magenta(cls) -> "Color":
|
100
|
+
return cls(0xE91E63)
|
101
|
+
|
102
|
+
@classmethod
|
103
|
+
def dark_magenta(cls) -> "Color":
|
104
|
+
return cls(0xAD1457)
|
105
|
+
|
106
|
+
@classmethod
|
107
|
+
def purple(cls) -> "Color":
|
108
|
+
return cls(0x9B59B6)
|
109
|
+
|
110
|
+
@classmethod
|
111
|
+
def dark_purple(cls) -> "Color":
|
112
|
+
return cls(0x71368A)
|
113
|
+
|
114
|
+
@classmethod
|
115
|
+
def yellow(cls) -> "Color":
|
116
|
+
return cls(0xF1C40F)
|
117
|
+
|
118
|
+
@classmethod
|
119
|
+
def dark_gold(cls) -> "Color":
|
120
|
+
return cls(0xC27C0E)
|
121
|
+
|
122
|
+
@classmethod
|
123
|
+
def light_gray(cls) -> "Color":
|
124
|
+
return cls(0x99AAB5)
|
125
|
+
|
126
|
+
@classmethod
|
127
|
+
def dark_gray(cls) -> "Color":
|
128
|
+
return cls(0x2C2F33)
|
129
|
+
|
130
|
+
@classmethod
|
131
|
+
def lighter_gray(cls) -> "Color":
|
132
|
+
return cls(0xBFBFBF)
|
133
|
+
|
134
|
+
@classmethod
|
135
|
+
def darker_gray(cls) -> "Color":
|
136
|
+
return cls(0x23272A)
|
137
|
+
|
138
|
+
@classmethod
|
139
|
+
def black(cls) -> "Color":
|
140
|
+
return cls(0x000000)
|
141
|
+
|
142
|
+
@classmethod
|
143
|
+
def white(cls) -> "Color":
|
144
|
+
return cls(0xFFFFFF)
|
145
|
+
|
49
146
|
def to_rgb(self) -> tuple[int, int, int]:
|
50
147
|
return ((self.value >> 16) & 0xFF, (self.value >> 8) & 0xFF, self.value & 0xFF)
|
51
148
|
|
52
149
|
@classmethod
|
53
|
-
def parse(
|
150
|
+
def parse(
|
151
|
+
cls, value: "Color | int | str | tuple[int, int, int] | None"
|
152
|
+
) -> "Color | None":
|
54
153
|
"""Convert ``value`` to a :class:`Color` instance.
|
55
154
|
|
56
155
|
Parameters
|
disagreement/enums.py
CHANGED
@@ -278,6 +278,62 @@ class GuildFeature(str, Enum): # Changed from IntEnum to Enum
|
|
278
278
|
return str(value)
|
279
279
|
|
280
280
|
|
281
|
+
# --- Guild Scheduled Event Enums ---
|
282
|
+
|
283
|
+
|
284
|
+
class GuildScheduledEventPrivacyLevel(IntEnum):
|
285
|
+
"""Privacy level for a scheduled event."""
|
286
|
+
|
287
|
+
GUILD_ONLY = 2
|
288
|
+
|
289
|
+
|
290
|
+
class GuildScheduledEventStatus(IntEnum):
|
291
|
+
"""Status of a scheduled event."""
|
292
|
+
|
293
|
+
SCHEDULED = 1
|
294
|
+
ACTIVE = 2
|
295
|
+
COMPLETED = 3
|
296
|
+
CANCELED = 4
|
297
|
+
|
298
|
+
|
299
|
+
class GuildScheduledEventEntityType(IntEnum):
|
300
|
+
"""Entity type for a scheduled event."""
|
301
|
+
|
302
|
+
STAGE_INSTANCE = 1
|
303
|
+
VOICE = 2
|
304
|
+
EXTERNAL = 3
|
305
|
+
|
306
|
+
|
307
|
+
class VoiceRegion(str, Enum):
|
308
|
+
"""Voice region identifier."""
|
309
|
+
|
310
|
+
AMSTERDAM = "amsterdam"
|
311
|
+
BRAZIL = "brazil"
|
312
|
+
DUBAI = "dubai"
|
313
|
+
EU_CENTRAL = "eu-central"
|
314
|
+
EU_WEST = "eu-west"
|
315
|
+
EUROPE = "europe"
|
316
|
+
FRANKFURT = "frankfurt"
|
317
|
+
HONGKONG = "hongkong"
|
318
|
+
INDIA = "india"
|
319
|
+
JAPAN = "japan"
|
320
|
+
RUSSIA = "russia"
|
321
|
+
SINGAPORE = "singapore"
|
322
|
+
SOUTHAFRICA = "southafrica"
|
323
|
+
SOUTH_KOREA = "south-korea"
|
324
|
+
SYDNEY = "sydney"
|
325
|
+
US_CENTRAL = "us-central"
|
326
|
+
US_EAST = "us-east"
|
327
|
+
US_SOUTH = "us-south"
|
328
|
+
US_WEST = "us-west"
|
329
|
+
VIP_US_EAST = "vip-us-east"
|
330
|
+
VIP_US_WEST = "vip-us-west"
|
331
|
+
|
332
|
+
@classmethod
|
333
|
+
def _missing_(cls, value): # type: ignore
|
334
|
+
return str(value)
|
335
|
+
|
336
|
+
|
281
337
|
# --- Channel Enums ---
|
282
338
|
|
283
339
|
|
@@ -305,6 +361,13 @@ class ChannelType(IntEnum):
|
|
305
361
|
GUILD_MEDIA = 16 # (Still in development) a channel that can only contain media
|
306
362
|
|
307
363
|
|
364
|
+
class StageInstancePrivacyLevel(IntEnum):
|
365
|
+
"""Privacy level of a stage instance."""
|
366
|
+
|
367
|
+
PUBLIC = 1
|
368
|
+
GUILD_ONLY = 2
|
369
|
+
|
370
|
+
|
308
371
|
class OverwriteType(IntEnum):
|
309
372
|
"""Type of target for a permission overwrite."""
|
310
373
|
|
@@ -1,7 +1,9 @@
|
|
1
1
|
# disagreement/ext/app_commands/handler.py
|
2
2
|
|
3
3
|
import inspect
|
4
|
+
import json
|
4
5
|
import logging
|
6
|
+
import os
|
5
7
|
from typing import (
|
6
8
|
TYPE_CHECKING,
|
7
9
|
Dict,
|
@@ -67,6 +69,8 @@ if not TYPE_CHECKING:
|
|
67
69
|
|
68
70
|
logger = logging.getLogger(__name__)
|
69
71
|
|
72
|
+
COMMANDS_CACHE_FILE = ".disagreement_commands.json"
|
73
|
+
|
70
74
|
|
71
75
|
class AppCommandHandler:
|
72
76
|
"""
|
@@ -84,6 +88,33 @@ class AppCommandHandler:
|
|
84
88
|
self._app_command_groups: Dict[str, AppCommandGroup] = {}
|
85
89
|
self._converter_registry: Dict[type, type] = {}
|
86
90
|
|
91
|
+
def _load_cached_ids(self) -> Dict[str, Dict[str, str]]:
|
92
|
+
try:
|
93
|
+
with open(COMMANDS_CACHE_FILE, "r", encoding="utf-8") as fp:
|
94
|
+
return json.load(fp)
|
95
|
+
except FileNotFoundError:
|
96
|
+
return {}
|
97
|
+
except json.JSONDecodeError:
|
98
|
+
logger.warning("Invalid command cache file. Ignoring.")
|
99
|
+
return {}
|
100
|
+
|
101
|
+
def _save_cached_ids(self, data: Dict[str, Dict[str, str]]) -> None:
|
102
|
+
try:
|
103
|
+
with open(COMMANDS_CACHE_FILE, "w", encoding="utf-8") as fp:
|
104
|
+
json.dump(data, fp, indent=2)
|
105
|
+
except Exception as e: # pragma: no cover - logging only
|
106
|
+
logger.error("Failed to write command cache: %s", e)
|
107
|
+
|
108
|
+
def clear_stored_registrations(self) -> None:
|
109
|
+
"""Remove persisted command registration data."""
|
110
|
+
if os.path.exists(COMMANDS_CACHE_FILE):
|
111
|
+
os.remove(COMMANDS_CACHE_FILE)
|
112
|
+
|
113
|
+
def migrate_stored_registrations(self, new_path: str) -> None:
|
114
|
+
"""Move stored registrations to ``new_path``."""
|
115
|
+
if os.path.exists(COMMANDS_CACHE_FILE):
|
116
|
+
os.replace(COMMANDS_CACHE_FILE, new_path)
|
117
|
+
|
87
118
|
def add_command(self, command: Union["AppCommand", "AppCommandGroup"]) -> None:
|
88
119
|
"""Adds an application command or a command group to the handler."""
|
89
120
|
if isinstance(command, AppCommandGroup):
|
@@ -564,11 +595,13 @@ class AppCommandHandler:
|
|
564
595
|
Synchronizes (registers/updates) all application commands with Discord.
|
565
596
|
If guild_id is provided, syncs commands for that guild. Otherwise, syncs global commands.
|
566
597
|
"""
|
567
|
-
|
598
|
+
cache = self._load_cached_ids()
|
599
|
+
scope_key = str(guild_id) if guild_id else "global"
|
600
|
+
stored = cache.get(scope_key, {})
|
568
601
|
|
569
|
-
|
570
|
-
# This needs to be more sophisticated to handle guild_ids on commands/groups
|
602
|
+
current_payloads: Dict[str, Dict[str, Any]] = {}
|
571
603
|
|
604
|
+
# Collect commands based on scope (global or specific guild)
|
572
605
|
source_commands = (
|
573
606
|
list(self._slash_commands.values())
|
574
607
|
+ list(self._user_commands.values())
|
@@ -577,26 +610,22 @@ class AppCommandHandler:
|
|
577
610
|
)
|
578
611
|
|
579
612
|
for cmd_or_group in source_commands:
|
580
|
-
# Determine if this command/group should be synced for the current scope
|
581
613
|
is_guild_specific_command = (
|
582
614
|
cmd_or_group.guild_ids is not None and len(cmd_or_group.guild_ids) > 0
|
583
615
|
)
|
584
616
|
|
585
|
-
if guild_id:
|
586
|
-
# Skip if not a guild-specific command OR if it's for a different guild
|
617
|
+
if guild_id:
|
587
618
|
if not is_guild_specific_command or (
|
588
619
|
cmd_or_group.guild_ids is not None
|
589
620
|
and guild_id not in cmd_or_group.guild_ids
|
590
621
|
):
|
591
622
|
continue
|
592
|
-
else:
|
623
|
+
else:
|
593
624
|
if is_guild_specific_command:
|
594
|
-
continue
|
625
|
+
continue
|
595
626
|
|
596
|
-
# Use the to_dict() method from AppCommand or AppCommandGroup
|
597
627
|
try:
|
598
|
-
|
599
|
-
commands_to_sync.append(payload)
|
628
|
+
current_payloads[cmd_or_group.name] = cmd_or_group.to_dict()
|
600
629
|
except AttributeError:
|
601
630
|
logger.warning(
|
602
631
|
"Command or group '%s' does not have a to_dict() method. Skipping.",
|
@@ -609,32 +638,74 @@ class AppCommandHandler:
|
|
609
638
|
e,
|
610
639
|
)
|
611
640
|
|
612
|
-
if not
|
641
|
+
if not current_payloads:
|
613
642
|
logger.info(
|
614
643
|
"No commands to sync for %s scope.",
|
615
644
|
f"guild {guild_id}" if guild_id else "global",
|
616
645
|
)
|
617
646
|
return
|
618
647
|
|
648
|
+
names_current = set(current_payloads)
|
649
|
+
names_stored = set(stored)
|
650
|
+
|
651
|
+
to_delete = names_stored - names_current
|
652
|
+
to_create = names_current - names_stored
|
653
|
+
to_update = names_current & names_stored
|
654
|
+
|
655
|
+
if not to_delete and not to_create and not to_update:
|
656
|
+
logger.info(
|
657
|
+
"Application commands already up to date for %s scope.", scope_key
|
658
|
+
)
|
659
|
+
return
|
660
|
+
|
619
661
|
try:
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
|
662
|
+
for name in to_delete:
|
663
|
+
cmd_id = stored[name]
|
664
|
+
if guild_id:
|
665
|
+
await self.client._http.delete_guild_application_command(
|
666
|
+
application_id, guild_id, cmd_id
|
667
|
+
)
|
668
|
+
else:
|
669
|
+
await self.client._http.delete_global_application_command(
|
670
|
+
application_id, cmd_id
|
671
|
+
)
|
672
|
+
|
673
|
+
new_ids: Dict[str, str] = {}
|
674
|
+
for name in to_create:
|
675
|
+
payload = current_payloads[name]
|
676
|
+
if guild_id:
|
677
|
+
result = await self.client._http.create_guild_application_command(
|
678
|
+
application_id, guild_id, payload
|
679
|
+
)
|
680
|
+
else:
|
681
|
+
result = await self.client._http.create_global_application_command(
|
682
|
+
application_id, payload
|
683
|
+
)
|
684
|
+
if result.id:
|
685
|
+
new_ids[name] = str(result.id)
|
686
|
+
|
687
|
+
for name in to_update:
|
688
|
+
payload = current_payloads[name]
|
689
|
+
cmd_id = stored[name]
|
690
|
+
if guild_id:
|
691
|
+
await self.client._http.edit_guild_application_command(
|
692
|
+
application_id, guild_id, cmd_id, payload
|
693
|
+
)
|
694
|
+
else:
|
695
|
+
await self.client._http.edit_global_application_command(
|
696
|
+
application_id, cmd_id, payload
|
697
|
+
)
|
698
|
+
new_ids[name] = cmd_id
|
699
|
+
|
700
|
+
final_ids: Dict[str, str] = {}
|
701
|
+
for name in names_current:
|
702
|
+
if name in new_ids:
|
703
|
+
final_ids[name] = new_ids[name]
|
704
|
+
else:
|
705
|
+
final_ids[name] = stored[name]
|
706
|
+
|
707
|
+
cache[scope_key] = final_ids
|
708
|
+
self._save_cached_ids(cache)
|
637
709
|
logger.info("Command sync successful.")
|
638
710
|
except Exception as e:
|
639
711
|
logger.error("Error syncing application commands: %s", e)
|
640
|
-
# Consider re-raising or specific error handling
|
@@ -16,6 +16,7 @@ from .decorators import (
|
|
16
16
|
check,
|
17
17
|
check_any,
|
18
18
|
cooldown,
|
19
|
+
max_concurrency,
|
19
20
|
requires_permissions,
|
20
21
|
)
|
21
22
|
from .errors import (
|
@@ -28,6 +29,7 @@ from .errors import (
|
|
28
29
|
CheckAnyFailure,
|
29
30
|
CommandOnCooldown,
|
30
31
|
CommandInvokeError,
|
32
|
+
MaxConcurrencyReached,
|
31
33
|
)
|
32
34
|
|
33
35
|
__all__ = [
|
@@ -43,6 +45,7 @@ __all__ = [
|
|
43
45
|
"check",
|
44
46
|
"check_any",
|
45
47
|
"cooldown",
|
48
|
+
"max_concurrency",
|
46
49
|
"requires_permissions",
|
47
50
|
# Errors
|
48
51
|
"CommandError",
|
@@ -54,4 +57,5 @@ __all__ = [
|
|
54
57
|
"CheckAnyFailure",
|
55
58
|
"CommandOnCooldown",
|
56
59
|
"CommandInvokeError",
|
60
|
+
"MaxConcurrencyReached",
|
57
61
|
]
|