Slipstream 66288ba920
Adds comprehensive Discord API error code mapping
Implements specific exception classes for all Discord API error codes to provide more granular error handling and better debugging experience.

Creates individual exception classes for each Discord error code (10001-520006) with descriptive names and documentation strings that include the official error messages and codes.

Updates HTTP client to automatically map Discord error codes to their corresponding exception classes, allowing developers to catch specific error types rather than generic HTTP exceptions.

Includes comprehensive test coverage for the new error code mapping functionality to ensure correct exception raising based on API responses.
2025-06-11 14:47:23 -06:00

1353 lines
50 KiB
Python

# disagreement/http.py
"""
HTTP client for interacting with the Discord REST API.
"""
import asyncio
import logging
import aiohttp # pylint: disable=import-error
import json
from urllib.parse import quote
from typing import Optional, Dict, Any, Union, TYPE_CHECKING, List
from .errors import * # Import all custom exceptions
from . import __version__ # For User-Agent
from .rate_limiter import RateLimiter
from .interactions import InteractionResponsePayload
if TYPE_CHECKING:
from .client import Client
from .models import Message, Webhook, File, StageInstance, Invite
from .interactions import ApplicationCommand, Snowflake
# Discord API constants
API_BASE_URL = "https://discord.com/api/v10" # Using API v10
logger = logging.getLogger(__name__)
DISCORD_ERROR_CODE_TO_EXCEPTION = {
0: GeneralError,
10001: UnknownAccount,
10002: UnknownApplication,
10003: UnknownChannel,
10004: UnknownGuild,
10005: UnknownIntegration,
10006: UnknownInvite,
10007: UnknownMember,
10008: UnknownMessage,
10009: UnknownPermissionOverwrite,
10010: UnknownProvider,
10011: UnknownRole,
10012: UnknownToken,
10013: UnknownUser,
10014: UnknownEmoji,
10015: UnknownWebhook,
10016: UnknownWebhookService,
10020: UnknownSession,
10021: UnknownAsset,
10026: UnknownBan,
10027: UnknownSKU,
10028: UnknownStoreListing,
10029: UnknownEntitlement,
10030: UnknownBuild,
10031: UnknownLobby,
10032: UnknownBranch,
10033: UnknownStoreDirectoryLayout,
10036: UnknownRedistributable,
10038: UnknownGiftCode,
10049: UnknownStream,
10050: UnknownPremiumServerSubscribeCooldown,
10057: UnknownGuildTemplate,
10059: UnknownDiscoverableServerCategory,
10060: UnknownSticker,
10061: UnknownStickerPack,
10062: UnknownInteraction,
10063: UnknownApplicationCommand,
10065: UnknownVoiceState,
10066: UnknownApplicationCommandPermissions,
10067: UnknownStageInstance,
10068: UnknownGuildMemberVerificationForm,
10069: UnknownGuildWelcomeScreen,
10070: UnknownGuildScheduledEvent,
10071: UnknownGuildScheduledEventUser,
10087: UnknownTag,
10097: UnknownSound,
20001: BotsCannotUseThisEndpoint,
20002: OnlyBotsCanUseThisEndpoint,
20009: ExplicitContentCannotBeSentToTheDesiredRecipients,
20012: NotAuthorizedToPerformThisActionOnThisApplication,
20016: ActionCannotBePerformedDueToSlowmodeRateLimit,
20018: OnlyTheOwnerOfThisAccountCanPerformThisAction,
20022: MessageCannotBeEditedDueToAnnouncementRateLimits,
20024: UnderMinimumAge,
20028: ChannelHitWriteRateLimit,
20029: ServerHitWriteRateLimit,
20031: DisallowedWordsInStageTopicOrNames,
20035: GuildPremiumSubscriptionLevelTooLow,
30001: MaximumNumberOfGuildsReached,
30002: MaximumNumberOfFriendsReached,
30003: MaximumNumberOfPinsReached,
30004: MaximumNumberOfRecipientsReached,
30005: MaximumNumberOfGuildRolesReached,
30007: MaximumNumberOfWebhooksReached,
30008: MaximumNumberOfEmojisReached,
30010: MaximumNumberOfReactionsReached,
30011: MaximumNumberOfGroupDMsReached,
30013: MaximumNumberOfGuildChannelsReached,
30015: MaximumNumberOfAttachmentsInAMessageReached,
30016: MaximumNumberOfInvitesReached,
30018: MaximumNumberOfAnimatedEmojisReached,
30019: MaximumNumberOfServerMembersReached,
30030: MaximumNumberOfServerCategoriesReached,
30031: GuildAlreadyHasATemplate,
30032: MaximumNumberOfApplicationCommandsReached,
30033: MaximumNumberOfThreadParticipantsReached,
30034: MaximumNumberOfDailyApplicationCommandCreatesReached,
30035: MaximumNumberOfBansForNonGuildMembersExceeded,
30037: MaximumNumberOfBansFetchesReached,
30038: MaximumNumberOfUncompletedGuildScheduledEventsReached,
30039: MaximumNumberOfStickersReached,
30040: MaximumNumberOfPruneRequestsReached,
30042: MaximumNumberOfGuildWidgetSettingsUpdatesReached,
30045: MaximumNumberOfSoundboardSoundsReached,
30046: MaximumNumberOfEditsToMessagesOlderThan1HourReached,
30047: MaximumNumberOfPinnedThreadsInAForumChannelReached,
30048: MaximumNumberOfTagsInAForumChannelReached,
30052: BitrateIsTooHighForChannelOfThisType,
30056: MaximumNumberOfPremiumEmojisReached,
30058: MaximumNumberOfWebhooksPerGuildReached,
30061: MaximumNumberOfChannelPermissionOverwritesReached,
30062: TheChannelsForThisGuildAreTooLarge,
40001: Unauthorized,
40002: YouNeedToVerifyYourAccount,
40003: YouAreOpeningDirectMessagesTooFast,
40004: SendMessagesHasBeenTemporarilyDisabled,
40005: RequestEntityTooLarge,
40006: ThisFeatureHasBeenTemporarilyDisabledServerSide,
40007: TheUserIsBannedFromThisGuild,
40012: ConnectionHasBeenRevoked,
40018: OnlyConsumableSKUsCanBeConsumed,
40019: YouCanOnlyDeleteSandboxEntitlements,
40032: TargetUserIsNotConnectedToVoice,
40033: ThisMessageHasAlreadyBeenCrossposted,
40041: AnApplicationCommandWithThatNameAlreadyExists,
40043: ApplicationInteractionFailedToSend,
40058: CannotSendAMessageInAForumChannel,
40060: InteractionHasAlreadyBeenAcknowledged,
40061: TagNamesMustBeUnique,
40062: ServiceResourceIsBeingRateLimited,
40066: ThereAreNoTagsAvailableThatCanBeSetByNonModerators,
40067: ATagIsRequiredToCreateAForumPostInThisChannel,
40074: AnEntitlementHasAlreadyBeenGrantedForThisResource,
40094: ThisInteractionHasHitTheMaximumNumberOfFollowUpMessages,
40333: CloudflareIsBlockingYourRequest,
50001: MissingAccess,
50002: InvalidAccountType,
50003: CannotExecuteActionOnADMChannel,
50004: GuildWidgetDisabled,
50005: CannotEditAMessageAuthoredByAnotherUser,
50006: CannotSendAnEmptyMessage,
50007: CannotSendMessagesToThisUser,
50008: CannotSendMessagesInANonTextChannel,
50009: ChannelVerificationLevelIsTooHighForYouToGainAccess,
50010: OAuth2ApplicationDoesNotHaveABot,
50011: OAuth2ApplicationLimitReached,
50012: InvalidOAuth2State,
50013: YouLackPermissionsToPerformThatAction,
50014: InvalidAuthenticationTokenProvided,
50015: NoteWasTooLong,
50016: ProvidedTooFewOrTooManyMessagesToDelete,
50017: InvalidMFALevel,
50019: AMessageCanOnlyBePinnedToTheChannelItWasSentIn,
50020: InviteCodeWasEitherInvalidOrTaken,
50021: CannotExecuteActionOnASystemMessage,
50024: CannotExecuteActionOnThisChannelType,
50025: InvalidOAuth2AccessTokenProvided,
50026: MissingRequiredOAuth2Scope,
50027: InvalidWebhookTokenProvided,
50028: InvalidRole,
50033: InvalidRecipients,
50034: AMessageProvidedWasTooOldToBulkDelete,
50035: InvalidFormBody,
50036: AnInviteWasAcceptedToAGuildTheApplicationBotIsNotIn,
50039: InvalidActivityAction,
50041: InvalidAPIVersionProvided,
50045: FileUploadedExceedsTheMaximumSize,
50046: InvalidFileUploaded,
50054: CannotSelfRedeemThisGift,
50055: InvalidGuild,
50057: InvalidSKU,
50067: InvalidRequestOrigin,
50068: InvalidMessageType,
50070: PaymentSourceRequiredToRedeemGift,
50073: CannotModifyASystemWebhook,
50074: CannotDeleteAChannelRequiredForCommunityGuilds,
50080: CannotEditStickersWithinAMessage,
50081: InvalidStickerSent,
50083: TriedToPerformAnOperationOnAnArchivedThread,
50085: InvalidThreadNotificationSettings,
50086: BeforeValueIsEarlierThanTheThreadCreationDate,
50087: CommunityServerChannelsMustBeTextChannels,
50091: TheEntityTypeOfTheEventIsDifferentFromTheEntityYouAreTryingToStartTheEventFor,
50095: ThisServerIsNotAvailableInYourLocation,
50097: ThisServerNeedsMonetizationEnabledInOrderToPerformThisAction,
50101: ThisServerNeedsMoreBoostsToPerformThisAction,
50109: TheRequestBodyContainsInvalidJSON,
50110: TheProvidedFileIsInvalid,
50123: TheProvidedFileTypeIsInvalid,
50124: TheProvidedFileDurationExceedsMaximumOf52Seconds,
50131: OwnerCannotBePendingMember,
50132: OwnershipCannotBeTransferredToABotUser,
50138: FailedToResizeAssetBelowTheMaximumSize,
50144: CannotMixSubscriptionAndNonSubscriptionRolesForAnEmoji,
50145: CannotConvertBetweenPremiumEmojiAndNormalEmoji,
50146: UploadedFileNotFound,
50151: TheSpecifiedEmojiIsInvalid,
50159: VoiceMessagesDoNotSupportAdditionalContent,
50160: VoiceMessagesMustHaveASingleAudioAttachment,
50161: VoiceMessagesMustHaveSupportingMetadata,
50162: VoiceMessagesCannotBeEdited,
50163: CannotDeleteGuildSubscriptionIntegration,
50173: YouCannotSendVoiceMessagesInThisChannel,
50178: TheUserAccountMustFirstBeVerified,
50192: TheProvidedFileDoesNotHaveAValidDuration,
50600: YouDoNotHavePermissionToSendThisSticker,
60003: TwoFactorIsRequiredForThisOperation,
80004: NoUsersWithDiscordTagExist,
90001: ReactionWasBlocked,
90002: UserCannotUseBurstReactions,
110001: ApplicationNotYetAvailable,
130000: APIResourceIsCurrentlyOverloaded,
150006: TheStageIsAlreadyOpen,
160002: CannotReplyWithoutPermissionToReadMessageHistory,
160004: AThreadHasAlreadyBeenCreatedForThisMessage,
160005: ThreadIsLocked,
160006: MaximumNumberOfActiveThreadsReached,
160007: MaximumNumberOfActiveAnnouncementThreadsReached,
170001: InvalidJSONForUploadedLottieFile,
170002: UploadedLottiesCannotContainRasterizedImages,
170003: StickerMaximumFramerateExceeded,
170004: StickerFrameCountExceedsMaximumOf1000Frames,
170005: LottieAnimationMaximumDimensionsExceeded,
170006: StickerFrameRateIsEitherTooSmallOrTooLarge,
170007: StickerAnimationDurationExceedsMaximumOf5Seconds,
180000: CannotUpdateAFinishedEvent,
180002: FailedToCreateStageNeededForStageEvent,
200000: MessageWasBlockedByAutomaticModeration,
200001: TitleWasBlockedByAutomaticModeration,
220001: WebhooksPostedToForumChannelsMustHaveAThreadNameOrThreadId,
220002: WebhooksPostedToForumChannelsCannotHaveBothAThreadNameAndThreadId,
220003: WebhooksCanOnlyCreateThreadsInForumChannels,
220004: WebhookServicesCannotBeUsedInForumChannels,
240000: MessageBlockedByHarmfulLinksFilter,
350000: CannotEnableOnboardingRequirementsAreNotMet,
350001: CannotUpdateOnboardingWhileBelowRequirements,
500000: FailedToBanUsers,
520000: PollVotingBlocked,
520001: PollExpired,
520002: InvalidChannelTypeForPollCreation,
520003: CannotEditAPollMessage,
520004: CannotUseAnEmojiIncludedWithThePoll,
520006: CannotExpireANonPollMessage,
}
class HTTPClient:
"""Handles HTTP requests to the Discord API."""
def __init__(
self,
token: str,
client_session: Optional[aiohttp.ClientSession] = None,
verbose: bool = False,
**session_kwargs: Any,
):
"""Create a new HTTP client.
Parameters
----------
token:
Bot token for authentication.
client_session:
Optional existing :class:`aiohttp.ClientSession`.
verbose:
If ``True``, log HTTP requests and responses.
**session_kwargs:
Additional options forwarded to :class:`aiohttp.ClientSession`, such
as ``proxy`` or ``connector``.
"""
self.token = token
self._session: Optional[aiohttp.ClientSession] = client_session
self._session_kwargs: Dict[str, Any] = session_kwargs
self.user_agent = f"DiscordBot (https://github.com/Slipstreamm/disagreement, {__version__})" # Customize URL
self.verbose = verbose
self._rate_limiter = RateLimiter()
async def _ensure_session(self):
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession(**self._session_kwargs)
async def close(self):
"""Closes the underlying aiohttp.ClientSession."""
if self._session and not self._session.closed:
await self._session.close()
async def request(
self,
method: str,
endpoint: str,
payload: Optional[
Union[Dict[str, Any], List[Dict[str, Any]], aiohttp.FormData]
] = None,
params: Optional[Dict[str, Any]] = None,
is_json: bool = True,
use_auth_header: bool = True,
custom_headers: Optional[Dict[str, str]] = None,
) -> Any:
"""Makes an HTTP request to the Discord API."""
await self._ensure_session()
url = f"{API_BASE_URL}{endpoint}"
final_headers: Dict[str, str] = { # Renamed to final_headers
"User-Agent": self.user_agent,
}
if use_auth_header:
final_headers["Authorization"] = f"Bot {self.token}"
if is_json and payload:
final_headers["Content-Type"] = "application/json"
if custom_headers: # Merge custom headers
final_headers.update(custom_headers)
if self.verbose:
logger.debug(
"HTTP REQUEST: %s %s | payload=%s params=%s",
method,
url,
payload,
params,
)
route = f"{method.upper()}:{endpoint}"
for attempt in range(5): # Max 5 retries for rate limits
await self._rate_limiter.acquire(route)
assert self._session is not None, "ClientSession not initialized"
async with self._session.request(
method,
url,
json=payload if is_json else None,
data=payload if not is_json else None,
headers=final_headers,
params=params,
) as response:
data = None
try:
if response.headers.get("Content-Type", "").startswith(
"application/json"
):
data = await response.json()
else:
# For non-JSON responses, like fetching images or other files
# We might return the raw response or handle it differently
# For now, let's assume most API calls expect JSON
data = await response.text()
except (aiohttp.ContentTypeError, json.JSONDecodeError):
data = (
await response.text()
) # Fallback to text if JSON parsing fails
if self.verbose:
logger.debug(
"HTTP RESPONSE: %s %s | %s", response.status, url, data
)
self._rate_limiter.release(route, response.headers)
if 200 <= response.status < 300:
if response.status == 204:
return None
return data
# Rate limit handling
if response.status == 429: # Rate limited
retry_after_str = response.headers.get("Retry-After", "1")
try:
retry_after = float(retry_after_str)
except ValueError:
retry_after = 1.0 # Default retry if header is malformed
is_global = (
response.headers.get("X-RateLimit-Global", "false").lower()
== "true"
)
error_message = f"Rate limited on {method} {endpoint}."
if data and isinstance(data, dict) and "message" in data:
error_message += f" Discord says: {data['message']}"
await self._rate_limiter.handle_rate_limit(
route, retry_after, is_global
)
if attempt < 4: # Don't log on the last attempt before raising
logger.warning(
"%s Retrying after %ss (Attempt %s/5). Global: %s",
error_message,
retry_after,
attempt + 1,
is_global,
)
continue # Retry the request
else: # Last attempt failed
raise RateLimitError(
response,
message=error_message,
retry_after=retry_after,
is_global=is_global,
)
# Other error handling
if response.status == 401: # Unauthorized
raise AuthenticationError(response, "Invalid token provided.")
if response.status == 403: # Forbidden
raise HTTPException(
response,
"Missing permissions or access denied.",
status=response.status,
text=str(data),
)
# General HTTP error
error_text = str(data) if data else "Unknown error"
discord_error_code = (
data.get("code") if isinstance(data, dict) else None
)
if discord_error_code in DISCORD_ERROR_CODE_TO_EXCEPTION:
exc_class = DISCORD_ERROR_CODE_TO_EXCEPTION[discord_error_code]
raise exc_class(
response,
f"API Error on {method} {endpoint}: {error_text}",
status=response.status,
text=error_text,
error_code=discord_error_code,
)
raise HTTPException(
response,
f"API Error on {method} {endpoint}: {error_text}",
status=response.status,
text=error_text,
error_code=discord_error_code,
)
# Should not be reached if retries are exhausted by RateLimitError
raise DisagreementException(
f"Failed request to {method} {endpoint} after multiple retries."
)
# --- Specific API call methods ---
async def get_gateway_bot(self) -> Dict[str, Any]:
"""Gets the WSS URL and sharding information for the Gateway."""
return await self.request("GET", "/gateway/bot")
async def send_message(
self,
channel_id: str,
content: Optional[str] = None,
tts: bool = False,
embeds: Optional[List[Dict[str, Any]]] = None,
components: Optional[List[Dict[str, Any]]] = None,
allowed_mentions: Optional[dict] = None,
message_reference: Optional[Dict[str, Any]] = None,
attachments: Optional[List[Any]] = None,
files: Optional[List[Any]] = None,
flags: Optional[int] = None,
) -> Dict[str, Any]:
"""Sends a message to a channel.
Parameters
----------
attachments:
A list of attachment payloads to include with the message.
files:
A list of :class:`File` objects containing binary data to upload.
Returns
-------
Dict[str, Any]
The created message data.
"""
payload: Dict[str, Any] = {}
if content is not None: # Content is optional if embeds/components are present
payload["content"] = content
if tts:
payload["tts"] = True
if embeds:
payload["embeds"] = embeds
if components:
payload["components"] = components
if allowed_mentions:
payload["allowed_mentions"] = allowed_mentions
all_files: List["File"] = []
if attachments is not None:
payload["attachments"] = []
for a in attachments:
if hasattr(a, "data") and hasattr(a, "filename"):
idx = len(all_files)
all_files.append(a)
payload["attachments"].append({"id": idx, "filename": a.filename})
else:
payload["attachments"].append(
a.to_dict() if hasattr(a, "to_dict") else a
)
if files is not None:
for f in files:
if hasattr(f, "data") and hasattr(f, "filename"):
idx = len(all_files)
all_files.append(f)
if "attachments" not in payload:
payload["attachments"] = []
payload["attachments"].append({"id": idx, "filename": f.filename})
else:
raise TypeError("files must be File objects")
if flags:
payload["flags"] = flags
if message_reference:
payload["message_reference"] = message_reference
if not payload:
raise ValueError("Message must have content, embeds, or components.")
if all_files:
form = aiohttp.FormData()
form.add_field(
"payload_json", json.dumps(payload), content_type="application/json"
)
for idx, f in enumerate(all_files):
form.add_field(
f"files[{idx}]",
f.data,
filename=f.filename,
content_type="application/octet-stream",
)
return await self.request(
"POST",
f"/channels/{channel_id}/messages",
payload=form,
is_json=False,
)
return await self.request(
"POST", f"/channels/{channel_id}/messages", payload=payload
)
async def edit_message(
self,
channel_id: str,
message_id: str,
payload: Dict[str, Any],
) -> Dict[str, Any]:
"""Edits a message in a channel."""
return await self.request(
"PATCH",
f"/channels/{channel_id}/messages/{message_id}",
payload=payload,
)
async def get_message(
self, channel_id: "Snowflake", message_id: "Snowflake"
) -> Dict[str, Any]:
"""Fetches a message from a channel."""
return await self.request(
"GET", f"/channels/{channel_id}/messages/{message_id}"
)
async def delete_message(
self, channel_id: "Snowflake", message_id: "Snowflake"
) -> None:
"""Deletes a message in a channel."""
await self.request("DELETE", f"/channels/{channel_id}/messages/{message_id}")
async def create_reaction(
self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
) -> None:
"""Adds a reaction to a message as the current user."""
encoded = quote(emoji)
await self.request(
"PUT",
f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/@me",
)
async def delete_reaction(
self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
) -> None:
"""Removes the current user's reaction from a message."""
encoded = quote(emoji)
await self.request(
"DELETE",
f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/@me",
)
async def delete_user_reaction(
self,
channel_id: "Snowflake",
message_id: "Snowflake",
emoji: str,
user_id: "Snowflake",
) -> None:
"""Removes another user's reaction from a message."""
encoded = quote(emoji)
await self.request(
"DELETE",
f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}/{user_id}",
)
async def get_reactions(
self, channel_id: "Snowflake", message_id: "Snowflake", emoji: str
) -> List[Dict[str, Any]]:
"""Fetches the users that reacted with a specific emoji."""
encoded = quote(emoji)
return await self.request(
"GET",
f"/channels/{channel_id}/messages/{message_id}/reactions/{encoded}",
)
async def clear_reactions(
self, channel_id: "Snowflake", message_id: "Snowflake"
) -> None:
"""Removes all reactions from a message."""
await self.request(
"DELETE",
f"/channels/{channel_id}/messages/{message_id}/reactions",
)
async def bulk_delete_messages(
self, channel_id: "Snowflake", messages: List["Snowflake"]
) -> List["Snowflake"]:
"""Bulk deletes messages in a channel and returns their IDs."""
await self.request(
"POST",
f"/channels/{channel_id}/messages/bulk-delete",
payload={"messages": messages},
)
return messages
async def get_pinned_messages(
self, channel_id: "Snowflake"
) -> List[Dict[str, Any]]:
"""Fetches all pinned messages in a channel."""
return await self.request("GET", f"/channels/{channel_id}/pins")
async def pin_message(
self, channel_id: "Snowflake", message_id: "Snowflake"
) -> None:
"""Pins a message in a channel."""
await self.request("PUT", f"/channels/{channel_id}/pins/{message_id}")
async def unpin_message(
self, channel_id: "Snowflake", message_id: "Snowflake"
) -> None:
"""Unpins a message from a channel."""
await self.request("DELETE", f"/channels/{channel_id}/pins/{message_id}")
async def delete_channel(
self, channel_id: str, reason: Optional[str] = None
) -> None:
"""Deletes a channel.
If the channel is a guild channel, requires the MANAGE_CHANNELS permission.
If the channel is a thread, requires the MANAGE_THREADS permission (if locked) or
be the thread creator (if not locked).
Deleting a category does not delete its child channels.
"""
custom_headers = {}
if reason:
custom_headers["X-Audit-Log-Reason"] = reason
await self.request(
"DELETE",
f"/channels/{channel_id}",
custom_headers=custom_headers if custom_headers else None,
)
async def edit_channel(
self,
channel_id: "Snowflake",
payload: Dict[str, Any],
reason: Optional[str] = None,
) -> Dict[str, Any]:
"""Edits a channel."""
headers = {"X-Audit-Log-Reason": reason} if reason else None
return await self.request(
"PATCH",
f"/channels/{channel_id}",
payload=payload,
custom_headers=headers,
)
async def get_channel(self, channel_id: str) -> Dict[str, Any]:
"""Fetches a channel by ID."""
return await self.request("GET", f"/channels/{channel_id}")
async def get_channel_invites(
self, channel_id: "Snowflake"
) -> List[Dict[str, Any]]:
"""Fetches the invites for a channel."""
return await self.request("GET", f"/channels/{channel_id}/invites")
async def create_invite(
self, channel_id: "Snowflake", payload: Dict[str, Any]
) -> "Invite":
"""Creates an invite for a channel."""
data = await self.request(
"POST", f"/channels/{channel_id}/invites", payload=payload
)
from .models import Invite
return Invite.from_dict(data)
async def delete_invite(self, code: str) -> None:
"""Deletes an invite by code."""
await self.request("DELETE", f"/invites/{code}")
async def create_webhook(
self, channel_id: "Snowflake", payload: Dict[str, Any]
) -> "Webhook":
"""Creates a webhook in the specified channel."""
data = await self.request(
"POST", f"/channels/{channel_id}/webhooks", payload=payload
)
from .models import Webhook
return Webhook(data)
async def edit_webhook(
self, webhook_id: "Snowflake", payload: Dict[str, Any]
) -> "Webhook":
"""Edits an existing webhook."""
data = await self.request("PATCH", f"/webhooks/{webhook_id}", payload=payload)
from .models import Webhook
return Webhook(data)
async def delete_webhook(self, webhook_id: "Snowflake") -> None:
"""Deletes a webhook."""
await self.request("DELETE", f"/webhooks/{webhook_id}")
async def execute_webhook(
self,
webhook_id: "Snowflake",
token: str,
*,
content: Optional[str] = None,
tts: bool = False,
embeds: Optional[List[Dict[str, Any]]] = None,
components: Optional[List[Dict[str, Any]]] = None,
allowed_mentions: Optional[dict] = None,
attachments: Optional[List[Any]] = None,
files: Optional[List[Any]] = None,
flags: Optional[int] = None,
username: Optional[str] = None,
avatar_url: Optional[str] = None,
) -> Dict[str, Any]:
"""Executes a webhook and returns the created message."""
payload: Dict[str, Any] = {}
if content is not None:
payload["content"] = content
if tts:
payload["tts"] = True
if embeds:
payload["embeds"] = embeds
if components:
payload["components"] = components
if allowed_mentions:
payload["allowed_mentions"] = allowed_mentions
if username:
payload["username"] = username
if avatar_url:
payload["avatar_url"] = avatar_url
all_files: List["File"] = []
if attachments is not None:
payload["attachments"] = []
for a in attachments:
if hasattr(a, "data") and hasattr(a, "filename"):
idx = len(all_files)
all_files.append(a)
payload["attachments"].append({"id": idx, "filename": a.filename})
else:
payload["attachments"].append(
a.to_dict() if hasattr(a, "to_dict") else a
)
if files is not None:
for f in files:
if hasattr(f, "data") and hasattr(f, "filename"):
idx = len(all_files)
all_files.append(f)
if "attachments" not in payload:
payload["attachments"] = []
payload["attachments"].append({"id": idx, "filename": f.filename})
else:
raise TypeError("files must be File objects")
if flags:
payload["flags"] = flags
if all_files:
form = aiohttp.FormData()
form.add_field(
"payload_json", json.dumps(payload), content_type="application/json"
)
for idx, f in enumerate(all_files):
form.add_field(
f"files[{idx}]",
f.data,
filename=f.filename,
content_type="application/octet-stream",
)
return await self.request(
"POST",
f"/webhooks/{webhook_id}/{token}",
payload=form,
is_json=False,
use_auth_header=False,
)
return await self.request(
"POST",
f"/webhooks/{webhook_id}/{token}",
payload=payload,
use_auth_header=False,
)
async def get_user(self, user_id: "Snowflake") -> Dict[str, Any]:
"""Fetches a user object for a given user ID."""
return await self.request("GET", f"/users/{user_id}")
async def get_guild_member(
self, guild_id: "Snowflake", user_id: "Snowflake"
) -> Dict[str, Any]:
"""Returns a guild member object for the specified user."""
return await self.request("GET", f"/guilds/{guild_id}/members/{user_id}")
async def kick_member(
self, guild_id: "Snowflake", user_id: "Snowflake", reason: Optional[str] = None
) -> None:
"""Kicks a member from the guild."""
headers = {"X-Audit-Log-Reason": reason} if reason else None
await self.request(
"DELETE",
f"/guilds/{guild_id}/members/{user_id}",
custom_headers=headers,
)
async def ban_member(
self,
guild_id: "Snowflake",
user_id: "Snowflake",
*,
delete_message_seconds: int = 0,
reason: Optional[str] = None,
) -> None:
"""Bans a member from the guild."""
payload = {}
if delete_message_seconds:
payload["delete_message_seconds"] = delete_message_seconds
headers = {"X-Audit-Log-Reason": reason} if reason else None
await self.request(
"PUT",
f"/guilds/{guild_id}/bans/{user_id}",
payload=payload if payload else None,
custom_headers=headers,
)
async def timeout_member(
self,
guild_id: "Snowflake",
user_id: "Snowflake",
*,
until: Optional[str],
reason: Optional[str] = None,
) -> Dict[str, Any]:
"""Times out a member until the given ISO8601 timestamp."""
payload = {"communication_disabled_until": until}
headers = {"X-Audit-Log-Reason": reason} if reason else None
return await self.request(
"PATCH",
f"/guilds/{guild_id}/members/{user_id}",
payload=payload,
custom_headers=headers,
)
async def get_guild_roles(self, guild_id: "Snowflake") -> List[Dict[str, Any]]:
"""Returns a list of role objects for the guild."""
return await self.request("GET", f"/guilds/{guild_id}/roles")
async def get_guild(self, guild_id: "Snowflake") -> Dict[str, Any]:
"""Fetches a guild object for a given guild ID."""
return await self.request("GET", f"/guilds/{guild_id}")
async def get_guild_templates(self, guild_id: "Snowflake") -> List[Dict[str, Any]]:
"""Fetches all templates for the given guild."""
return await self.request("GET", f"/guilds/{guild_id}/templates")
async def create_guild_template(
self, guild_id: "Snowflake", payload: Dict[str, Any]
) -> Dict[str, Any]:
"""Creates a guild template."""
return await self.request(
"POST", f"/guilds/{guild_id}/templates", payload=payload
)
async def sync_guild_template(
self, guild_id: "Snowflake", template_code: str
) -> Dict[str, Any]:
"""Syncs a guild template to the guild's current state."""
return await self.request(
"PUT",
f"/guilds/{guild_id}/templates/{template_code}",
)
async def delete_guild_template(
self, guild_id: "Snowflake", template_code: str
) -> None:
"""Deletes a guild template."""
await self.request("DELETE", f"/guilds/{guild_id}/templates/{template_code}")
async def get_guild_scheduled_events(
self, guild_id: "Snowflake"
) -> List[Dict[str, Any]]:
"""Returns a list of scheduled events for the guild."""
return await self.request("GET", f"/guilds/{guild_id}/scheduled-events")
async def get_guild_scheduled_event(
self, guild_id: "Snowflake", event_id: "Snowflake"
) -> Dict[str, Any]:
"""Returns a guild scheduled event."""
return await self.request(
"GET", f"/guilds/{guild_id}/scheduled-events/{event_id}"
)
async def create_guild_scheduled_event(
self, guild_id: "Snowflake", payload: Dict[str, Any]
) -> Dict[str, Any]:
"""Creates a guild scheduled event."""
return await self.request(
"POST", f"/guilds/{guild_id}/scheduled-events", payload=payload
)
async def edit_guild_scheduled_event(
self, guild_id: "Snowflake", event_id: "Snowflake", payload: Dict[str, Any]
) -> Dict[str, Any]:
"""Edits a guild scheduled event."""
return await self.request(
"PATCH",
f"/guilds/{guild_id}/scheduled-events/{event_id}",
payload=payload,
)
async def delete_guild_scheduled_event(
self, guild_id: "Snowflake", event_id: "Snowflake"
) -> None:
"""Deletes a guild scheduled event."""
await self.request("DELETE", f"/guilds/{guild_id}/scheduled-events/{event_id}")
async def get_audit_logs(
self, guild_id: "Snowflake", **filters: Any
) -> Dict[str, Any]:
"""Fetches audit log entries for a guild."""
params = {k: v for k, v in filters.items() if v is not None}
return await self.request(
"GET",
f"/guilds/{guild_id}/audit-logs",
params=params if params else None,
)
# Add other methods like:
# async def get_guild(self, guild_id: str) -> Dict[str, Any]: ...
# async def create_reaction(self, channel_id: str, message_id: str, emoji: str) -> None: ...
# etc.
# --- Application Command Endpoints ---
# Global Application Commands
async def get_global_application_commands(
self, application_id: "Snowflake", with_localizations: bool = False
) -> List["ApplicationCommand"]:
"""Fetches all global commands for your application."""
params = {"with_localizations": str(with_localizations).lower()}
data = await self.request(
"GET", f"/applications/{application_id}/commands", params=params
)
from .interactions import ApplicationCommand # Ensure constructor is available
return [ApplicationCommand(cmd_data) for cmd_data in data]
async def create_global_application_command(
self, application_id: "Snowflake", payload: Dict[str, Any]
) -> "ApplicationCommand":
"""Creates a new global command."""
data = await self.request(
"POST", f"/applications/{application_id}/commands", payload=payload
)
from .interactions import ApplicationCommand
return ApplicationCommand(data)
async def get_global_application_command(
self, application_id: "Snowflake", command_id: "Snowflake"
) -> "ApplicationCommand":
"""Fetches a specific global command."""
data = await self.request(
"GET", f"/applications/{application_id}/commands/{command_id}"
)
from .interactions import ApplicationCommand
return ApplicationCommand(data)
async def edit_global_application_command(
self,
application_id: "Snowflake",
command_id: "Snowflake",
payload: Dict[str, Any],
) -> "ApplicationCommand":
"""Edits a specific global command."""
data = await self.request(
"PATCH",
f"/applications/{application_id}/commands/{command_id}",
payload=payload,
)
from .interactions import ApplicationCommand
return ApplicationCommand(data)
async def delete_global_application_command(
self, application_id: "Snowflake", command_id: "Snowflake"
) -> None:
"""Deletes a specific global command."""
await self.request(
"DELETE", f"/applications/{application_id}/commands/{command_id}"
)
async def bulk_overwrite_global_application_commands(
self, application_id: "Snowflake", payload: List[Dict[str, Any]]
) -> List["ApplicationCommand"]:
"""Bulk overwrites all global commands for your application."""
data = await self.request(
"PUT", f"/applications/{application_id}/commands", payload=payload
)
from .interactions import ApplicationCommand
return [ApplicationCommand(cmd_data) for cmd_data in data]
# Guild Application Commands
async def get_guild_application_commands(
self,
application_id: "Snowflake",
guild_id: "Snowflake",
with_localizations: bool = False,
) -> List["ApplicationCommand"]:
"""Fetches all commands for your application for a specific guild."""
params = {"with_localizations": str(with_localizations).lower()}
data = await self.request(
"GET",
f"/applications/{application_id}/guilds/{guild_id}/commands",
params=params,
)
from .interactions import ApplicationCommand
return [ApplicationCommand(cmd_data) for cmd_data in data]
async def create_guild_application_command(
self,
application_id: "Snowflake",
guild_id: "Snowflake",
payload: Dict[str, Any],
) -> "ApplicationCommand":
"""Creates a new guild command."""
data = await self.request(
"POST",
f"/applications/{application_id}/guilds/{guild_id}/commands",
payload=payload,
)
from .interactions import ApplicationCommand
return ApplicationCommand(data)
async def get_guild_application_command(
self,
application_id: "Snowflake",
guild_id: "Snowflake",
command_id: "Snowflake",
) -> "ApplicationCommand":
"""Fetches a specific guild command."""
data = await self.request(
"GET",
f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
)
from .interactions import ApplicationCommand
return ApplicationCommand(data)
async def edit_guild_application_command(
self,
application_id: "Snowflake",
guild_id: "Snowflake",
command_id: "Snowflake",
payload: Dict[str, Any],
) -> "ApplicationCommand":
"""Edits a specific guild command."""
data = await self.request(
"PATCH",
f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
payload=payload,
)
from .interactions import ApplicationCommand
return ApplicationCommand(data)
async def delete_guild_application_command(
self,
application_id: "Snowflake",
guild_id: "Snowflake",
command_id: "Snowflake",
) -> None:
"""Deletes a specific guild command."""
await self.request(
"DELETE",
f"/applications/{application_id}/guilds/{guild_id}/commands/{command_id}",
)
async def bulk_overwrite_guild_application_commands(
self,
application_id: "Snowflake",
guild_id: "Snowflake",
payload: List[Dict[str, Any]],
) -> List["ApplicationCommand"]:
"""Bulk overwrites all commands for your application for a specific guild."""
data = await self.request(
"PUT",
f"/applications/{application_id}/guilds/{guild_id}/commands",
payload=payload,
)
from .interactions import ApplicationCommand
return [ApplicationCommand(cmd_data) for cmd_data in data]
# --- Interaction Response Endpoints ---
# Note: These methods return Dict[str, Any] representing the Message data.
# The caller (e.g., AppCommandHandler) will be responsible for constructing Message models
# if needed, as Message model instantiation requires a `client_instance`.
async def create_interaction_response(
self,
interaction_id: "Snowflake",
interaction_token: str,
payload: Union["InteractionResponsePayload", Dict[str, Any]],
*,
ephemeral: bool = False,
) -> None:
"""Creates a response to an Interaction.
Parameters
----------
ephemeral: bool
Ignored parameter for test compatibility.
"""
# Interaction responses do not use the bot token in the Authorization header.
# They are authenticated by the interaction_token in the URL.
payload_data: Dict[str, Any]
if isinstance(payload, InteractionResponsePayload):
payload_data = payload.to_dict()
else:
payload_data = payload
await self.request(
"POST",
f"/interactions/{interaction_id}/{interaction_token}/callback",
payload=payload_data,
use_auth_header=False,
)
async def get_original_interaction_response(
self, application_id: "Snowflake", interaction_token: str
) -> Dict[str, Any]:
"""Gets the initial Interaction response."""
# This endpoint uses the bot token for auth.
return await self.request(
"GET", f"/webhooks/{application_id}/{interaction_token}/messages/@original"
)
async def edit_original_interaction_response(
self,
application_id: "Snowflake",
interaction_token: str,
payload: Dict[str, Any],
) -> Dict[str, Any]:
"""Edits the initial Interaction response."""
return await self.request(
"PATCH",
f"/webhooks/{application_id}/{interaction_token}/messages/@original",
payload=payload,
use_auth_header=False,
) # Docs imply webhook-style auth
async def delete_original_interaction_response(
self, application_id: "Snowflake", interaction_token: str
) -> None:
"""Deletes the initial Interaction response."""
await self.request(
"DELETE",
f"/webhooks/{application_id}/{interaction_token}/messages/@original",
use_auth_header=False,
) # Docs imply webhook-style auth
async def create_followup_message(
self,
application_id: "Snowflake",
interaction_token: str,
payload: Dict[str, Any],
) -> Dict[str, Any]:
"""Creates a followup message for an Interaction."""
# Followup messages are sent to a webhook endpoint.
return await self.request(
"POST",
f"/webhooks/{application_id}/{interaction_token}",
payload=payload,
use_auth_header=False,
) # Docs imply webhook-style auth
async def edit_followup_message(
self,
application_id: "Snowflake",
interaction_token: str,
message_id: "Snowflake",
payload: Dict[str, Any],
) -> Dict[str, Any]:
"""Edits a followup message for an Interaction."""
return await self.request(
"PATCH",
f"/webhooks/{application_id}/{interaction_token}/messages/{message_id}",
payload=payload,
use_auth_header=False,
) # Docs imply webhook-style auth
async def delete_followup_message(
self,
application_id: "Snowflake",
interaction_token: str,
message_id: "Snowflake",
) -> None:
"""Deletes a followup message for an Interaction."""
await self.request(
"DELETE",
f"/webhooks/{application_id}/{interaction_token}/messages/{message_id}",
use_auth_header=False,
)
async def trigger_typing(self, channel_id: str) -> None:
"""Sends a typing indicator to the specified channel."""
await self.request("POST", f"/channels/{channel_id}/typing")
async def start_stage_instance(
self, payload: Dict[str, Any], reason: Optional[str] = None
) -> "StageInstance":
"""Starts a stage instance."""
headers = {"X-Audit-Log-Reason": reason} if reason else None
data = await self.request(
"POST", "/stage-instances", payload=payload, custom_headers=headers
)
from .models import StageInstance
return StageInstance(data)
async def edit_stage_instance(
self,
channel_id: "Snowflake",
payload: Dict[str, Any],
reason: Optional[str] = None,
) -> "StageInstance":
"""Edits an existing stage instance."""
headers = {"X-Audit-Log-Reason": reason} if reason else None
data = await self.request(
"PATCH",
f"/stage-instances/{channel_id}",
payload=payload,
custom_headers=headers,
)
from .models import StageInstance
return StageInstance(data)
async def end_stage_instance(
self, channel_id: "Snowflake", reason: Optional[str] = None
) -> None:
"""Ends a stage instance."""
headers = {"X-Audit-Log-Reason": reason} if reason else None
await self.request(
"DELETE", f"/stage-instances/{channel_id}", custom_headers=headers
)
async def get_voice_regions(self) -> List[Dict[str, Any]]:
"""Returns available voice regions."""
return await self.request("GET", "/voice/regions")
async def start_thread_from_message(
self,
channel_id: "Snowflake",
message_id: "Snowflake",
payload: Dict[str, Any],
) -> Dict[str, Any]:
"""Starts a new thread from an existing message."""
return await self.request(
"POST",
f"/channels/{channel_id}/messages/{message_id}/threads",
payload=payload,
)
async def start_thread_without_message(
self, channel_id: "Snowflake", payload: Dict[str, Any]
) -> Dict[str, Any]:
"""Starts a new thread that is not attached to a message."""
return await self.request(
"POST", f"/channels/{channel_id}/threads", payload=payload
)
async def join_thread(self, channel_id: "Snowflake") -> None:
"""Joins the current user to a thread."""
await self.request("PUT", f"/channels/{channel_id}/thread-members/@me")
async def leave_thread(self, channel_id: "Snowflake") -> None:
"""Removes the current user from a thread."""
await self.request("DELETE", f"/channels/{channel_id}/thread-members/@me")