use std::time::Duration;
use serde::Serialize;
use crate::types::{
AllowedMentions, Component, Embed, File, InteractionResponse, Message, MessageCallbackData,
MessageFlags, PartialAttachment, PollCreate, Snowflake,
};
use crate::command::{View, ViewError};
use super::{Body, Response, Rest, Result, Transport, route::routes};
routes! {
CREATE_INTERACTION_RESPONSE = Post "/interactions/{interaction_id}/{interaction_token}/callback";
GET_ORIGINAL_RESPONSE = Get "/webhooks/{webhook_id}/{webhook_token}/messages/@original";
EDIT_ORIGINAL_RESPONSE = Patch "/webhooks/{webhook_id}/{webhook_token}/messages/@original";
DELETE_ORIGINAL_RESPONSE = Delete "/webhooks/{webhook_id}/{webhook_token}/messages/@original";
CREATE_FOLLOWUP_MESSAGE = Post "/webhooks/{webhook_id}/{webhook_token}";
GET_FOLLOWUP_MESSAGE = Get "/webhooks/{webhook_id}/{webhook_token}/messages/{message_id}";
EDIT_FOLLOWUP_MESSAGE = Patch "/webhooks/{webhook_id}/{webhook_token}/messages/{message_id}";
DELETE_FOLLOWUP_MESSAGE = Delete "/webhooks/{webhook_id}/{webhook_token}/messages/{message_id}";
}
#[derive(Debug, Clone)]
pub struct InteractionClient<T> {
rest: Rest<T>,
application_id: Snowflake,
interaction_id: Snowflake,
token: String,
}
impl<T: Transport> InteractionClient<T> {
pub fn new(
transport: T,
application_id: Snowflake,
interaction_id: Snowflake,
token: impl Into<String>,
) -> Self {
Self {
rest: Rest::new(transport, None),
application_id,
interaction_id,
token: token.into(),
}
}
pub fn with_base_url(mut self, base: impl Into<String>) -> Self {
self.rest.set_base(base.into());
self
}
pub fn with_max_rate_limit_wait(mut self, wait: Duration) -> Self {
self.rest.set_max_rate_limit_wait(wait);
self
}
pub fn transport(&self) -> &T {
self.rest.transport()
}
pub async fn create_response(
&self,
response: &InteractionResponse,
files: &[File],
) -> Result<()> {
let url = self.rest.url(
CREATE_INTERACTION_RESPONSE,
&[&self.interaction_id, &self.token],
);
self.rest
.call_empty(
CREATE_INTERACTION_RESPONSE,
url,
Body::WithFiles(response, files),
None,
)
.await
}
pub async fn get_original(&self) -> Result<Response<Message>> {
let url = self.webhook_url(GET_ORIGINAL_RESPONSE);
self.rest
.call(GET_ORIGINAL_RESPONSE, url, Body::NONE, None)
.await
}
pub async fn edit_original(&self, message: &EditMessage<'_>) -> Result<Response<Message>> {
let url = self.webhook_url(EDIT_ORIGINAL_RESPONSE);
self.rest
.call(
EDIT_ORIGINAL_RESPONSE,
url,
Body::WithFiles(message, message.files),
None,
)
.await
}
pub async fn delete_original(&self) -> Result<()> {
let url = self.webhook_url(DELETE_ORIGINAL_RESPONSE);
self.rest
.call_empty(DELETE_ORIGINAL_RESPONSE, url, Body::NONE, None)
.await
}
pub async fn create_followup(&self, message: &CreateFollowup<'_>) -> Result<Response<Message>> {
let url = self.webhook_url(CREATE_FOLLOWUP_MESSAGE);
self.rest
.call(
CREATE_FOLLOWUP_MESSAGE,
url,
Body::WithFiles(message, message.files),
None,
)
.await
}
pub async fn followup(&self, content: &str) -> Result<Response<Message>> {
self.create_followup(&CreateFollowup {
content: Some(content),
..Default::default()
})
.await
}
pub async fn get_followup(&self, message_id: Snowflake) -> Result<Response<Message>> {
let url = self.message_url(GET_FOLLOWUP_MESSAGE, message_id);
self.rest
.call(GET_FOLLOWUP_MESSAGE, url, Body::NONE, None)
.await
}
pub async fn edit_followup(
&self,
message_id: Snowflake,
message: &EditMessage<'_>,
) -> Result<Response<Message>> {
let url = self.message_url(EDIT_FOLLOWUP_MESSAGE, message_id);
self.rest
.call(
EDIT_FOLLOWUP_MESSAGE,
url,
Body::WithFiles(message, message.files),
None,
)
.await
}
pub async fn delete_followup(&self, message_id: Snowflake) -> Result<()> {
let url = self.message_url(DELETE_FOLLOWUP_MESSAGE, message_id);
self.rest
.call_empty(DELETE_FOLLOWUP_MESSAGE, url, Body::NONE, None)
.await
}
fn webhook_url(&self, route: super::Route) -> String {
self.rest.url(route, &[&self.application_id, &self.token])
}
fn message_url(&self, route: super::Route, message_id: Snowflake) -> String {
self.rest
.url(route, &[&self.application_id, &self.token, &message_id])
}
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct CreateFollowup<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tts: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embeds: Option<&'a [Embed]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_mentions: Option<&'a AllowedMentions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub components: Option<&'a [Component]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachments: Option<&'a [PartialAttachment]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flags: Option<MessageFlags>,
#[serde(skip_serializing_if = "Option::is_none")]
pub poll: Option<&'a PollCreate>,
#[serde(skip)]
pub files: &'a [File],
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct EditMessage<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embeds: Option<&'a [Embed]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flags: Option<MessageFlags>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_mentions: Option<&'a AllowedMentions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub components: Option<&'a [Component]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachments: Option<&'a [PartialAttachment]>,
#[serde(skip)]
pub files: &'a [File],
}
impl<'a> TryFrom<&'a View> for CreateFollowup<'a> {
type Error = ViewError;
fn try_from(view: &'a View) -> std::result::Result<Self, ViewError> {
view.check()?;
Ok(CreateFollowup {
components: Some(view.components()),
flags: Some(view.message_flags(true)),
allowed_mentions: view.mentions(),
files: view.files(),
..Default::default()
})
}
}
impl<'a> TryFrom<&'a View> for EditMessage<'a> {
type Error = ViewError;
fn try_from(view: &'a View) -> std::result::Result<Self, ViewError> {
view.check()?;
Ok(EditMessage {
components: Some(view.components()),
flags: Some(view.message_flags(false)),
allowed_mentions: view.mentions(),
files: view.files(),
..Default::default()
})
}
}
impl<'a> From<&'a MessageCallbackData> for CreateFollowup<'a> {
fn from(data: &'a MessageCallbackData) -> Self {
CreateFollowup {
content: data.content.as_deref(),
tts: data.tts,
embeds: data.embeds.as_deref(),
allowed_mentions: data.allowed_mentions.as_ref(),
components: data.components.as_deref(),
attachments: data.attachments.as_deref(),
flags: data.flags,
poll: data.poll.as_ref(),
files: &data.files,
}
}
}
impl<'a> From<&'a MessageCallbackData> for EditMessage<'a> {
fn from(data: &'a MessageCallbackData) -> Self {
EditMessage {
content: data.content.as_deref(),
embeds: data.embeds.as_deref(),
flags: data.flags,
allowed_mentions: data.allowed_mentions.as_ref(),
components: data.components.as_deref(),
attachments: data.attachments.as_deref(),
files: &data.files,
}
}
}
#[cfg(test)]
mod tests {
use futures::executor::block_on;
use serde_json::json;
use super::super::{Method, testing::MockTransport};
use super::*;
use crate::types::Embed;
fn client(transport: MockTransport) -> InteractionClient<MockTransport> {
InteractionClient::new(
transport,
Snowflake::from_u64(1),
Snowflake::from_u64(2),
"tok",
)
}
const MESSAGE: &str = r#"{"id":"10","channel_id":"11","author":{"id":"1","username":"bot","discriminator":"0","global_name":null,"avatar":null},"content":"hi","timestamp":"2024-01-01T00:00:00.000000+00:00","edited_timestamp":null,"tts":false,"mention_everyone":false,"mentions":[],"mention_roles":[],"attachments":[],"embeds":[],"pinned":false,"type":0}"#;
#[test]
fn followup_posts_to_webhook_without_auth() {
let client = client(MockTransport::default().respond(200, MESSAGE));
let transport = client.transport();
let message = block_on(client.followup("hi")).unwrap().model().unwrap();
assert_eq!(message.content, "hi");
let request = transport.request(0);
assert_eq!(request.method, Method::Post);
assert_eq!(request.url, "https://discord.com/api/v10/webhooks/1/tok");
assert_eq!(request.header("authorization"), None);
assert_eq!(transport.body(0), json!({"content": "hi"}));
}
fn empty_client() -> InteractionClient<MockTransport> {
InteractionClient::new(
MockTransport::default().respond(204, ""),
Snowflake::from_u64(1),
Snowflake::from_u64(2),
"tok",
)
}
#[test]
fn routes() {
let c = empty_client();
block_on(c.create_response(
&InteractionResponse::DeferredChannelMessageWithSource(None),
&[],
))
.unwrap();
block_on(c.get_original()).unwrap();
block_on(c.edit_original(&EditMessage::default())).unwrap();
block_on(c.delete_original()).unwrap();
block_on(c.get_followup(Snowflake::from_u64(9))).unwrap();
block_on(c.edit_followup(Snowflake::from_u64(9), &EditMessage::default())).unwrap();
block_on(c.delete_followup(Snowflake::from_u64(9))).unwrap();
let base = "https://discord.com/api/v10";
let sent: Vec<_> = c
.transport()
.requests()
.iter()
.map(|r| (r.method, r.url.strip_prefix(base).unwrap().to_string()))
.collect();
assert_eq!(
sent,
[
(Method::Post, "/interactions/2/tok/callback".to_string()),
(
Method::Get,
"/webhooks/1/tok/messages/@original".to_string()
),
(
Method::Patch,
"/webhooks/1/tok/messages/@original".to_string()
),
(
Method::Delete,
"/webhooks/1/tok/messages/@original".to_string()
),
(Method::Get, "/webhooks/1/tok/messages/9".to_string()),
(Method::Patch, "/webhooks/1/tok/messages/9".to_string()),
(Method::Delete, "/webhooks/1/tok/messages/9".to_string()),
]
);
assert_eq!(c.transport().body(0), json!({"type": 5}));
}
#[test]
fn edit_from_callback_data_and_files_go_multipart() {
let c = empty_client();
let InteractionResponse::ChannelMessageWithSource(data) =
InteractionResponse::respond_with_embed(Embed::default())
else {
unreachable!()
};
let files = [File::new("a.png", vec![1, 2, 3])];
let edit = EditMessage {
files: &files,
..EditMessage::from(&data)
};
block_on(c.edit_original(&edit)).unwrap();
let request = c.transport().request(0);
assert!(
request
.header("content-type")
.unwrap()
.starts_with("multipart/form-data; boundary=")
);
let body = String::from_utf8_lossy(request.body.as_deref().unwrap()).into_owned();
assert!(body.contains(r#"name="files[0]"; filename="a.png""#));
assert!(body.contains(r#""embeds":[{"#));
}
#[test]
fn follow_ups_and_edits_from_a_view() {
use crate::command::View;
use crate::types::Component;
let c = empty_client();
let view = View::new(vec![Component::text("hi")])
.ephemeral()
.file(File::new("a.png", vec![1]));
block_on(c.create_followup(&CreateFollowup::try_from(&view).unwrap())).unwrap();
let body = c.transport().text(0);
assert!(body.contains(r#""flags":32832"#), "{body}");
assert!(body.contains(r#"filename="a.png""#), "{body}");
let view = View::new(vec![Component::text("edited")]).ephemeral();
block_on(c.edit_original(&EditMessage::try_from(&view).unwrap())).unwrap();
assert_eq!(
c.transport().body(1),
serde_json::json!({ "components": [{ "type": 10, "content": "edited" }], "flags": 1 << 15 })
);
}
}