mod builder;
pub use self::builder::ClientBuilder;
pub use reqwest::Proxy;
use crate::{
api_error::{ApiError, ErrorCode},
error::{Error, Result, UrlError},
ratelimiting::{RatelimitHeaders, Ratelimiter},
request::{
channel::message::allowed_mentions::AllowedMentions,
guild::{create_guild::CreateGuildError, create_guild_channel::CreateGuildChannelError},
prelude::*,
GetUserApplicationInfo, Request,
},
API_VERSION,
};
use bytes::Bytes;
use reqwest::{
header::{HeaderValue, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
Body, Client as ReqwestClient, Method, Response, StatusCode,
};
use serde::de::DeserializeOwned;
use std::{
convert::TryFrom,
fmt::{Debug, Formatter, Result as FmtResult},
result::Result as StdResult,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
use twilight_model::{
guild::Permissions,
id::{ChannelId, EmojiId, GuildId, IntegrationId, MessageId, RoleId, UserId, WebhookId},
};
use url::Url;
struct State {
http: ReqwestClient,
ratelimiter: Option<Ratelimiter>,
token_invalid: AtomicBool,
token: Option<String>,
use_http: bool,
pub(crate) default_allowed_mentions: Option<AllowedMentions>,
}
impl Debug for State {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("State")
.field("http", &"Reqwest HTTP client")
.field("ratelimiter", &self.ratelimiter)
.field("token", &self.token)
.field("use_http", &self.use_http)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct Client {
state: Arc<State>,
}
impl Client {
pub fn new(token: impl Into<String>) -> Self {
let mut token = token.into();
let is_bot = token.starts_with("Bot ");
let is_bearer = token.starts_with("Bearer ");
if !is_bot && !is_bearer {
token.insert_str(0, "Bot ");
}
Self {
state: Arc::new(State {
http: ReqwestClient::new(),
ratelimiter: Some(Ratelimiter::new()),
token_invalid: AtomicBool::new(false),
token: Some(token),
use_http: false,
default_allowed_mentions: None,
}),
}
}
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub fn token(&self) -> Option<&str> {
self.state.token.as_ref().map(AsRef::as_ref)
}
pub fn default_allowed_mentions(&self) -> Option<AllowedMentions> {
self.state.default_allowed_mentions.clone()
}
pub fn ratelimiter(&self) -> Option<Ratelimiter> {
self.state.ratelimiter.clone()
}
pub fn add_role(
&self,
guild_id: GuildId,
user_id: UserId,
role_id: RoleId,
) -> AddRoleToMember<'_> {
AddRoleToMember::new(self, guild_id, user_id, role_id)
}
pub fn audit_log(&self, guild_id: GuildId) -> GetAuditLog<'_> {
GetAuditLog::new(self, guild_id)
}
pub fn bans(&self, guild_id: GuildId) -> GetBans<'_> {
GetBans::new(self, guild_id)
}
pub fn ban(&self, guild_id: GuildId, user_id: UserId) -> GetBan<'_> {
GetBan::new(self, guild_id, user_id)
}
pub fn create_ban(&self, guild_id: GuildId, user_id: UserId) -> CreateBan<'_> {
CreateBan::new(self, guild_id, user_id)
}
pub fn delete_ban(&self, guild_id: GuildId, user_id: UserId) -> DeleteBan<'_> {
DeleteBan::new(self, guild_id, user_id)
}
pub fn channel(&self, channel_id: ChannelId) -> GetChannel<'_> {
GetChannel::new(self, channel_id)
}
pub fn delete_channel(&self, channel_id: ChannelId) -> DeleteChannel<'_> {
DeleteChannel::new(self, channel_id)
}
pub fn update_channel(&self, channel_id: ChannelId) -> UpdateChannel<'_> {
UpdateChannel::new(self, channel_id)
}
pub fn follow_news_channel(
&self,
channel_id: ChannelId,
webhook_channel_id: ChannelId,
) -> FollowNewsChannel<'_> {
FollowNewsChannel::new(self, channel_id, webhook_channel_id)
}
pub fn channel_invites(&self, channel_id: ChannelId) -> GetChannelInvites<'_> {
GetChannelInvites::new(self, channel_id)
}
pub fn channel_messages(&self, channel_id: ChannelId) -> GetChannelMessages<'_> {
GetChannelMessages::new(self, channel_id)
}
pub fn delete_channel_permission(&self, channel_id: ChannelId) -> DeleteChannelPermission<'_> {
DeleteChannelPermission::new(self, channel_id)
}
pub fn update_channel_permission(
&self,
channel_id: ChannelId,
allow: Permissions,
deny: Permissions,
) -> UpdateChannelPermission<'_> {
UpdateChannelPermission::new(self, channel_id, allow, deny)
}
pub fn channel_webhooks(&self, channel_id: ChannelId) -> GetChannelWebhooks<'_> {
GetChannelWebhooks::new(self, channel_id)
}
pub fn current_user(&self) -> GetCurrentUser<'_> {
GetCurrentUser::new(self)
}
pub fn current_user_application(&self) -> GetUserApplicationInfo<'_> {
GetUserApplicationInfo::new(self)
}
pub fn update_current_user(&self) -> UpdateCurrentUser<'_> {
UpdateCurrentUser::new(self)
}
pub fn current_user_connections(&self) -> GetCurrentUserConnections<'_> {
GetCurrentUserConnections::new(self)
}
pub fn current_user_guilds(&self) -> GetCurrentUserGuilds<'_> {
GetCurrentUserGuilds::new(self)
}
pub fn update_current_user_nick(
&self,
guild_id: GuildId,
nick: impl Into<String>,
) -> UpdateCurrentUserNick<'_> {
UpdateCurrentUserNick::new(self, guild_id, nick)
}
pub fn current_user_private_channels(&self) -> GetCurrentUserPrivateChannels<'_> {
GetCurrentUserPrivateChannels::new(self)
}
pub fn emojis(&self, guild_id: GuildId) -> GetEmojis<'_> {
GetEmojis::new(self, guild_id)
}
pub fn emoji(&self, guild_id: GuildId, emoji_id: EmojiId) -> GetEmoji<'_> {
GetEmoji::new(self, guild_id, emoji_id)
}
pub fn create_emoji(
&self,
guild_id: GuildId,
name: impl Into<String>,
image: impl Into<String>,
) -> CreateEmoji<'_> {
CreateEmoji::new(self, guild_id, name, image)
}
pub fn delete_emoji(&self, guild_id: GuildId, emoji_id: EmojiId) -> DeleteEmoji<'_> {
DeleteEmoji::new(self, guild_id, emoji_id)
}
pub fn update_emoji(&self, guild_id: GuildId, emoji_id: EmojiId) -> UpdateEmoji<'_> {
UpdateEmoji::new(self, guild_id, emoji_id)
}
pub fn gateway(&self) -> GetGateway<'_> {
GetGateway::new(self)
}
pub fn guild(&self, guild_id: GuildId) -> GetGuild<'_> {
GetGuild::new(self, guild_id)
}
pub fn create_guild(
&self,
name: impl Into<String>,
) -> StdResult<CreateGuild<'_>, CreateGuildError> {
CreateGuild::new(self, name)
}
pub fn delete_guild(&self, guild_id: GuildId) -> DeleteGuild<'_> {
DeleteGuild::new(self, guild_id)
}
pub fn update_guild(&self, guild_id: GuildId) -> UpdateGuild<'_> {
UpdateGuild::new(self, guild_id)
}
pub fn leave_guild(&self, guild_id: GuildId) -> LeaveGuild<'_> {
LeaveGuild::new(self, guild_id)
}
pub fn guild_channels(&self, guild_id: GuildId) -> GetGuildChannels<'_> {
GetGuildChannels::new(self, guild_id)
}
pub fn create_guild_channel(
&self,
guild_id: GuildId,
name: impl Into<String>,
) -> StdResult<CreateGuildChannel<'_>, CreateGuildChannelError> {
CreateGuildChannel::new(self, guild_id, name)
}
pub fn update_guild_channel_positions(
&self,
guild_id: GuildId,
channel_positions: impl Iterator<Item = (ChannelId, u64)>,
) -> UpdateGuildChannelPositions<'_> {
UpdateGuildChannelPositions::new(self, guild_id, channel_positions)
}
pub fn guild_widget(&self, guild_id: GuildId) -> GetGuildWidget<'_> {
GetGuildWidget::new(self, guild_id)
}
pub fn update_guild_widget(&self, guild_id: GuildId) -> UpdateGuildWidget<'_> {
UpdateGuildWidget::new(self, guild_id)
}
pub fn guild_integrations(&self, guild_id: GuildId) -> GetGuildIntegrations<'_> {
GetGuildIntegrations::new(self, guild_id)
}
pub fn create_guild_integration(
&self,
guild_id: GuildId,
integration_id: IntegrationId,
kind: impl Into<String>,
) -> CreateGuildIntegration<'_> {
CreateGuildIntegration::new(self, guild_id, integration_id, kind)
}
pub fn delete_guild_integration(
&self,
guild_id: GuildId,
integration_id: IntegrationId,
) -> DeleteGuildIntegration<'_> {
DeleteGuildIntegration::new(self, guild_id, integration_id)
}
pub fn update_guild_integration(
&self,
guild_id: GuildId,
integration_id: IntegrationId,
) -> UpdateGuildIntegration<'_> {
UpdateGuildIntegration::new(self, guild_id, integration_id)
}
pub fn sync_guild_integration(
&self,
guild_id: GuildId,
integration_id: IntegrationId,
) -> SyncGuildIntegration<'_> {
SyncGuildIntegration::new(self, guild_id, integration_id)
}
pub fn guild_invites(&self, guild_id: GuildId) -> GetGuildInvites<'_> {
GetGuildInvites::new(self, guild_id)
}
pub fn guild_members(&self, guild_id: GuildId) -> GetGuildMembers<'_> {
GetGuildMembers::new(self, guild_id)
}
pub fn guild_member(&self, guild_id: GuildId, user_id: UserId) -> GetMember<'_> {
GetMember::new(self, guild_id, user_id)
}
pub fn remove_guild_member(&self, guild_id: GuildId, user_id: UserId) -> RemoveMember<'_> {
RemoveMember::new(self, guild_id, user_id)
}
pub fn update_guild_member(&self, guild_id: GuildId, user_id: UserId) -> UpdateGuildMember<'_> {
UpdateGuildMember::new(self, guild_id, user_id)
}
pub fn add_guild_member_role(
&self,
guild_id: GuildId,
user_id: UserId,
role_id: RoleId,
) -> AddRoleToMember<'_> {
AddRoleToMember::new(self, guild_id, user_id, role_id)
}
pub fn remove_guild_member_role(
&self,
guild_id: GuildId,
user_id: UserId,
role_id: RoleId,
) -> RemoveRoleFromMember<'_> {
RemoveRoleFromMember::new(self, guild_id, user_id, role_id)
}
pub fn guild_preview(&self, guild_id: GuildId) -> GetGuildPreview<'_> {
GetGuildPreview::new(self, guild_id)
}
pub fn guild_prune_count(&self, guild_id: GuildId) -> GetGuildPruneCount<'_> {
GetGuildPruneCount::new(self, guild_id)
}
pub fn create_guild_prune(&self, guild_id: GuildId) -> CreateGuildPrune<'_> {
CreateGuildPrune::new(self, guild_id)
}
pub fn guild_vanity_url(&self, guild_id: GuildId) -> GetGuildVanityUrl<'_> {
GetGuildVanityUrl::new(self, guild_id)
}
pub fn guild_voice_regions(&self, guild_id: GuildId) -> GetGuildVoiceRegions<'_> {
GetGuildVoiceRegions::new(self, guild_id)
}
pub fn guild_webhooks(&self, guild_id: GuildId) -> GetGuildWebhooks<'_> {
GetGuildWebhooks::new(self, guild_id)
}
pub fn invite(&self, code: impl Into<String>) -> GetInvite<'_> {
GetInvite::new(self, code)
}
pub fn create_invite(&self, channel_id: ChannelId) -> CreateInvite<'_> {
CreateInvite::new(self, channel_id)
}
pub fn delete_invite(&self, code: impl Into<String>) -> DeleteInvite<'_> {
DeleteInvite::new(self, code)
}
pub fn message(&self, channel_id: ChannelId, message_id: MessageId) -> GetMessage<'_> {
GetMessage::new(self, channel_id, message_id)
}
pub fn create_message(&self, channel_id: ChannelId) -> CreateMessage<'_> {
CreateMessage::new(self, channel_id)
}
pub fn delete_message(
&self,
channel_id: ChannelId,
message_id: MessageId,
) -> DeleteMessage<'_> {
DeleteMessage::new(self, channel_id, message_id)
}
pub fn delete_messages(
&self,
channel_id: ChannelId,
message_ids: impl Into<Vec<MessageId>>,
) -> DeleteMessages<'_> {
DeleteMessages::new(self, channel_id, message_ids)
}
pub fn update_message(
&self,
channel_id: ChannelId,
message_id: MessageId,
) -> UpdateMessage<'_> {
UpdateMessage::new(self, channel_id, message_id)
}
pub fn crosspost_message(
&self,
channel_id: ChannelId,
message_id: MessageId,
) -> CrosspostMessage<'_> {
CrosspostMessage::new(self, channel_id, message_id)
}
pub fn pins(&self, channel_id: ChannelId) -> GetPins<'_> {
GetPins::new(self, channel_id)
}
pub fn create_pin(&self, channel_id: ChannelId, message_id: MessageId) -> CreatePin<'_> {
CreatePin::new(self, channel_id, message_id)
}
pub fn delete_pin(&self, channel_id: ChannelId, message_id: MessageId) -> DeletePin<'_> {
DeletePin::new(self, channel_id, message_id)
}
pub fn reactions(
&self,
channel_id: ChannelId,
message_id: MessageId,
emoji: RequestReactionType,
) -> GetReactions<'_> {
GetReactions::new(self, channel_id, message_id, emoji)
}
pub fn create_reaction(
&self,
channel_id: ChannelId,
message_id: MessageId,
emoji: RequestReactionType,
) -> CreateReaction<'_> {
CreateReaction::new(self, channel_id, message_id, emoji)
}
pub fn delete_current_user_reaction(
&self,
channel_id: ChannelId,
message_id: MessageId,
emoji: RequestReactionType,
) -> DeleteReaction<'_> {
DeleteReaction::new(self, channel_id, message_id, emoji, "@me")
}
pub fn delete_reaction(
&self,
channel_id: ChannelId,
message_id: MessageId,
emoji: RequestReactionType,
user_id: UserId,
) -> DeleteReaction<'_> {
DeleteReaction::new(self, channel_id, message_id, emoji, user_id.to_string())
}
pub fn delete_all_reaction(
&self,
channel_id: ChannelId,
message_id: MessageId,
emoji: RequestReactionType,
) -> DeleteAllReaction<'_> {
DeleteAllReaction::new(self, channel_id, message_id, emoji)
}
pub fn delete_all_reactions(
&self,
channel_id: ChannelId,
message_id: MessageId,
) -> DeleteAllReactions<'_> {
DeleteAllReactions::new(self, channel_id, message_id)
}
pub fn create_typing_trigger(&self, channel_id: ChannelId) -> CreateTypingTrigger<'_> {
CreateTypingTrigger::new(self, channel_id)
}
pub fn create_private_channel(&self, recipient_id: UserId) -> CreatePrivateChannel<'_> {
CreatePrivateChannel::new(self, recipient_id)
}
pub fn roles(&self, guild_id: GuildId) -> GetGuildRoles<'_> {
GetGuildRoles::new(self, guild_id)
}
pub fn create_role(&self, guild_id: GuildId) -> CreateRole<'_> {
CreateRole::new(self, guild_id)
}
pub fn delete_role(&self, guild_id: GuildId, role_id: RoleId) -> DeleteRole<'_> {
DeleteRole::new(self, guild_id, role_id)
}
pub fn update_role(&self, guild_id: GuildId, role_id: RoleId) -> UpdateRole<'_> {
UpdateRole::new(self, guild_id, role_id)
}
pub fn update_role_positions(
&self,
guild_id: GuildId,
roles: impl Iterator<Item = (RoleId, u64)>,
) -> UpdateRolePositions<'_> {
UpdateRolePositions::new(self, guild_id, roles)
}
pub fn user(&self, user_id: UserId) -> GetUser<'_> {
GetUser::new(self, user_id.to_string())
}
pub fn voice_regions(&self) -> GetVoiceRegions<'_> {
GetVoiceRegions::new(self)
}
pub fn webhook(&self, id: WebhookId) -> GetWebhook<'_> {
GetWebhook::new(self, id)
}
pub fn create_webhook(
&self,
channel_id: ChannelId,
name: impl Into<String>,
) -> CreateWebhook<'_> {
CreateWebhook::new(self, channel_id, name)
}
pub fn delete_webhook(&self, id: WebhookId) -> DeleteWebhook<'_> {
DeleteWebhook::new(self, id)
}
pub fn delete_webhook_from_url(&self, url: impl AsRef<str>) -> Result<DeleteWebhook<'_>> {
let (id, _) = parse_webhook_url(url)?;
Ok(self.delete_webhook(id))
}
pub fn update_webhook(&self, webhook_id: WebhookId) -> UpdateWebhook<'_> {
UpdateWebhook::new(self, webhook_id)
}
pub fn update_webhook_from_url(&self, url: impl AsRef<str>) -> Result<UpdateWebhook<'_>> {
let (id, _) = parse_webhook_url(url)?;
Ok(self.update_webhook(id))
}
pub fn update_webhook_with_token(
&self,
webhook_id: WebhookId,
token: impl Into<String>,
) -> UpdateWebhookWithToken<'_> {
UpdateWebhookWithToken::new(self, webhook_id, token)
}
pub fn update_webhook_with_token_from_url(
&self,
url: impl AsRef<str>,
) -> Result<UpdateWebhookWithToken<'_>> {
let (id, token) = parse_webhook_url(url)?;
Ok(self.update_webhook_with_token(id, token.ok_or(UrlError::SegmentMissing)?))
}
pub fn execute_webhook(
&self,
webhook_id: WebhookId,
token: impl Into<String>,
) -> ExecuteWebhook<'_> {
ExecuteWebhook::new(self, webhook_id, token)
}
pub fn execute_webhook_from_url(&self, url: impl AsRef<str>) -> Result<ExecuteWebhook<'_>> {
let (id, token) = parse_webhook_url(url)?;
Ok(self.execute_webhook(id, token.ok_or(UrlError::SegmentMissing)?))
}
pub async fn raw(&self, request: Request) -> Result<Response> {
if self.state.token_invalid.load(Ordering::Relaxed) {
return Err(Error::Unauthorized);
}
let Request {
body,
form,
headers: req_headers,
method,
path: bucket,
path_str: path,
} = request;
let protocol = if self.state.use_http { "http" } else { "https" };
let url = format!("{}://discord.com/api/v{}/{}", protocol, API_VERSION, path);
tracing::debug!("URL: {:?}", url);
let mut builder = self.state.http.request(method.clone(), &url);
if let Some(ref token) = self.state.token {
let value = HeaderValue::from_str(&token).map_err(|source| {
#[allow(clippy::borrow_interior_mutable_const)]
let name = AUTHORIZATION.to_string();
Error::CreatingHeader { name, source }
})?;
builder = builder.header(AUTHORIZATION, value);
}
if let Some(form) = form {
builder = builder.multipart(form);
} else if let Some(bytes) = body {
let len = bytes.len();
builder = builder.body(Body::from(bytes));
builder = builder.header(CONTENT_LENGTH, len);
let content_type = HeaderValue::from_static("application/json");
builder = builder.header(CONTENT_TYPE, content_type);
} else if method == Method::PUT || method == Method::POST || method == Method::PATCH {
builder = builder.header(CONTENT_LENGTH, 0);
}
let user_agent = HeaderValue::from_static(concat!(
"DiscordBot (",
env!("CARGO_PKG_HOMEPAGE"),
", ",
env!("CARGO_PKG_VERSION"),
") Twilight-rs",
));
builder = builder.header(USER_AGENT, user_agent);
if let Some(req_headers) = req_headers {
builder = builder.headers(req_headers);
}
let ratelimiter = match self.state.ratelimiter.as_ref() {
Some(ratelimiter) => ratelimiter,
None => {
return builder
.send()
.await
.map_err(|source| Error::RequestError { source })
}
};
let rx = ratelimiter.get(bucket).await;
let tx = rx
.await
.map_err(|source| Error::RequestCanceled { source })?;
let resp = builder
.send()
.await
.map_err(|source| Error::RequestError { source })?;
if resp.status() == StatusCode::UNAUTHORIZED {
self.state.token_invalid.store(true, Ordering::Relaxed);
}
match RatelimitHeaders::try_from(resp.headers()) {
Ok(v) => {
let _ = tx.send(Some(v));
}
Err(why) => {
tracing::warn!("header parsing failed: {:?}; {:?}", why, resp);
let _ = tx.send(None);
}
}
Ok(resp)
}
pub async fn request<T: DeserializeOwned>(&self, request: Request) -> Result<T> {
let resp = self.make_request(request).await?;
let bytes = resp
.bytes()
.await
.map_err(|source| Error::ChunkingResponse { source })?;
let mut bytes_b = bytes.as_ref().to_vec();
let result = crate::json_from_slice(&mut bytes_b);
result.map_err(|source| Error::Parsing {
body: (*bytes).to_vec(),
source,
})
}
pub(crate) async fn request_bytes(&self, request: Request) -> Result<Bytes> {
let resp = self.make_request(request).await?;
resp.bytes()
.await
.map_err(|source| Error::ChunkingResponse { source })
}
pub async fn verify(&self, request: Request) -> Result<()> {
self.make_request(request).await?;
Ok(())
}
async fn make_request(&self, request: Request) -> Result<Response> {
let resp = self.raw(request).await?;
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
match status {
StatusCode::IM_A_TEAPOT => tracing::warn!(
"discord's api now runs off of teapots -- proceed to panic: {:?}",
resp,
),
StatusCode::TOO_MANY_REQUESTS => tracing::warn!("429 response: {:?}", resp),
StatusCode::SERVICE_UNAVAILABLE => {
return Err(Error::ServiceUnavailable { response: resp })
}
_ => {}
}
let bytes = resp
.bytes()
.await
.map_err(|source| Error::ChunkingResponse { source })?;
let mut bytes_b = bytes.as_ref().to_vec();
let error =
crate::json_from_slice::<ApiError>(&mut bytes_b).map_err(|source| Error::Parsing {
body: bytes.to_vec(),
source,
})?;
if let ApiError::General(ref general) = error {
if let ErrorCode::Other(num) = general.code {
tracing::debug!("got unknown API error code variant: {}; {:?}", num, error);
}
}
Err(Error::Response {
body: bytes.as_ref().to_vec(),
error,
status,
})
}
}
impl From<ReqwestClient> for Client {
fn from(reqwest_client: ReqwestClient) -> Self {
Self {
state: Arc::new(State {
http: reqwest_client,
ratelimiter: Some(Ratelimiter::new()),
token_invalid: AtomicBool::new(false),
token: None,
use_http: false,
default_allowed_mentions: None,
}),
}
}
}
fn parse_webhook_url(
url: impl AsRef<str>,
) -> std::result::Result<(WebhookId, Option<String>), UrlError> {
let url = Url::parse(url.as_ref())?;
let mut segments = url.path_segments().ok_or(UrlError::SegmentMissing)?;
segments
.next()
.filter(|s| s == &"api")
.ok_or(UrlError::SegmentMissing)?;
segments
.next()
.filter(|s| s == &"webhooks")
.ok_or(UrlError::SegmentMissing)?;
let id = segments.next().ok_or(UrlError::SegmentMissing)?;
let token = segments.next();
Ok((WebhookId(id.parse()?), token.map(String::from)))
}
#[cfg(test)]
mod tests {
use super::{parse_webhook_url, WebhookId};
use std::error::Error;
#[test]
fn parse_webhook_id() -> Result<(), Box<dyn Error>> {
assert_eq!(
parse_webhook_url("https://discord.com/api/webhooks/123")?,
(WebhookId(123), None)
);
assert!(parse_webhook_url("https://discord.com/foo/bar/456").is_err());
assert!(parse_webhook_url("https://discord.com/api/webhooks/").is_err());
Ok(())
}
#[test]
fn parse_webhook_token() -> Result<(), Box<dyn Error>> {
assert_eq!(
parse_webhook_url("https://discord.com/api/webhooks/456/token")?,
(WebhookId(456), Some("token".into()))
);
assert_eq!(
parse_webhook_url("https://discord.com/api/webhooks/456/token/slack")?,
(WebhookId(456), Some("token".into()))
);
Ok(())
}
}