diff --git a/Cargo.toml b/Cargo.toml index 6c15102..e87e747 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ serde_json = { version = "1.0.103", features = ["raw_value"] } serde-aux = "4.2.0" serde_with = "3.0.0" serde_repr = "0.1.14" -reqwest = { version = "0.11.18", features = ["multipart"] } +reqwest = { version = "0.11.18", features = ["multipart", "json"] } url = "2.4.0" chrono = { version = "0.4.26", features = ["serde"] } regex = "1.9.1" diff --git a/src/api/channels/messages.rs b/src/api/channels/messages.rs index 9487100..cc5aa16 100644 --- a/src/api/channels/messages.rs +++ b/src/api/channels/messages.rs @@ -1,13 +1,15 @@ use http::header::CONTENT_DISPOSITION; use http::HeaderMap; use reqwest::{multipart, Client}; -use serde_json::to_string; +use serde_json::{from_value, to_string, Value}; use crate::api::LimitType; -use crate::errors::ChorusResult; +use crate::errors::{ChorusError, ChorusResult}; use crate::instance::UserMeta; use crate::ratelimiter::ChorusRequest; -use crate::types::{Message, MessageSendSchema, Snowflake}; +use crate::types::{ + Channel, Message, MessageSearchEndpoint, MessageSearchQuery, MessageSendSchema, Snowflake, +}; impl Message { /// Sends a message in the channel with the provided channel_id. @@ -70,6 +72,71 @@ impl Message { chorus_request.deserialize_response::(user).await } } + + /// Returns messages without the reactions key that match a search query in the guild or channel. + /// The messages that are direct results will have an extra hit key set to true. + /// If operating on a guild channel, this endpoint requires the `READ_MESSAGE_HISTORY` + /// permission to be present on the current user. + /// + /// If the guild/channel you are searching is not yet indexed, the endpoint will return a 202 accepted response. + /// In this case, the method will return a [`ChorusError::InvalidResponse`] error. + /// + /// # Reference: + /// See + pub(crate) async fn search( + endpoint: MessageSearchEndpoint, + query: MessageSearchQuery, + user: &mut UserMeta, + ) -> ChorusResult> { + let limit_type = match &endpoint { + MessageSearchEndpoint::Channel(id) => LimitType::Channel(*id), + MessageSearchEndpoint::GuildChannel(id) => LimitType::Guild(*id), + }; + let request = ChorusRequest { + limit_type, + request: Client::new() + .get(format!( + "{}/{}/messages/search", + &user.belongs_to.borrow().urls.api, + endpoint + )) + .header("Authorization", user.token()) + .body(to_string(&query).unwrap()), + }; + let result = request.send_request(user).await?; + let result_json = result.json::().await.unwrap(); + if !result_json.is_object() { + return Err(search_error(result_json.to_string())); + } + let value_map = result_json.as_object().unwrap(); + if let Some(messages) = value_map.get("messages") { + if let Ok(response) = from_value::>>(messages.clone()) { + let result_messages: Vec = response.into_iter().flatten().collect(); + return Ok(result_messages); + } + } + // The code below might be incorrect. We'll cross that bridge when we come to it + if !value_map.contains_key("code") || !value_map.contains_key("retry_after") { + return Err(search_error(result_json.to_string())); + } + let code = value_map.get("code").unwrap().as_u64().unwrap(); + let retry_after = value_map.get("retry_after").unwrap().as_u64().unwrap(); + Err(ChorusError::NotFound { + error: format!( + "Index not yet available. Try again later. Code: {}. Retry after {}s", + code, retry_after + ), + }) + } +} + +fn search_error(result_text: String) -> ChorusError { + ChorusError::InvalidResponse { + error: format!( + "Got unexpected Response, or Response which is not valid JSON. Response: \n{}", + result_text + ), + } } impl UserMeta { @@ -89,3 +156,23 @@ impl UserMeta { Message::send(self, channel_id, message).await } } + +impl Channel { + /// Returns messages without the reactions key that match a search query in the channel. + /// The messages that are direct results will have an extra hit key set to true. + /// If operating on a guild channel, this endpoint requires the `READ_MESSAGE_HISTORY` + /// permission to be present on the current user. + /// + /// If the guild/channel you are searching is not yet indexed, the endpoint will return a 202 accepted response. + /// In this case, the method will return a [`ChorusError::InvalidResponse`] error. + /// + /// # Reference: + /// See + pub async fn search_messages( + channel_id: Snowflake, + query: MessageSearchQuery, + user: &mut UserMeta, + ) -> ChorusResult> { + Message::search(MessageSearchEndpoint::Channel(channel_id), query, user).await + } +} diff --git a/src/api/guilds/messages.rs b/src/api/guilds/messages.rs new file mode 100644 index 0000000..72d886c --- /dev/null +++ b/src/api/guilds/messages.rs @@ -0,0 +1,28 @@ +use crate::errors::ChorusResult; +use crate::instance::UserMeta; +use crate::types::{Guild, Message, MessageSearchQuery, Snowflake}; + +impl Guild { + /// Returns messages without the reactions key that match a search query in the guild. + /// The messages that are direct results will have an extra hit key set to true. + /// If operating on a guild channel, this endpoint requires the `READ_MESSAGE_HISTORY` + /// permission to be present on the current user. + /// + /// If the guild/channel you are searching is not yet indexed, the endpoint will return a 202 accepted response. + /// In this case, the method will return a [`ChorusError::InvalidResponse`] error. + /// + /// # Reference: + /// See + pub async fn search_messages( + guild_id: Snowflake, + query: MessageSearchQuery, + user: &mut UserMeta, + ) -> ChorusResult> { + Message::search( + crate::types::MessageSearchEndpoint::GuildChannel(guild_id), + query, + user, + ) + .await + } +} diff --git a/src/api/guilds/mod.rs b/src/api/guilds/mod.rs index 7577479..f1fa039 100644 --- a/src/api/guilds/mod.rs +++ b/src/api/guilds/mod.rs @@ -1,7 +1,9 @@ pub use guilds::*; +pub use messages::*; pub use roles::*; pub use roles::*; pub mod guilds; pub mod member; +pub mod messages; pub mod roles; diff --git a/src/types/entities/attachment.rs b/src/types/entities/attachment.rs index 75ec860..59ad53d 100644 --- a/src/types/entities/attachment.rs +++ b/src/types/entities/attachment.rs @@ -31,7 +31,7 @@ pub struct Attachment { pub content: Option>, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct PartialDiscordFileAttachment { pub id: Option, pub filename: String, diff --git a/src/types/entities/channel.rs b/src/types/entities/channel.rs index 361ecb5..84530c9 100644 --- a/src/types/entities/channel.rs +++ b/src/types/entities/channel.rs @@ -176,10 +176,22 @@ pub struct DefaultReaction { pub emoji_name: Option, } -#[derive(Default, Clone, Copy, Debug, Serialize_repr, Deserialize_repr, PartialEq, Eq, Hash)] +#[derive( + Default, + Clone, + Copy, + Debug, + Serialize_repr, + Deserialize_repr, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, +)] #[cfg_attr(feature = "sqlx", derive(sqlx::Type))] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[repr(i32)] +#[repr(u32)] /// # Reference /// See pub enum ChannelType { diff --git a/src/types/entities/message.rs b/src/types/entities/message.rs index d7ef01c..4b03649 100644 --- a/src/types/entities/message.rs +++ b/src/types/entities/message.rs @@ -114,7 +114,7 @@ pub struct ChannelMention { name: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Embed { title: Option, #[serde(rename = "type")] @@ -132,14 +132,14 @@ pub struct Embed { fields: Option>, } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct EmbedFooter { text: String, icon_url: Option, proxy_icon_url: Option, } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct EmbedImage { url: String, proxy_url: String, @@ -147,7 +147,7 @@ pub struct EmbedImage { width: Option, } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct EmbedThumbnail { url: String, proxy_url: Option, @@ -155,7 +155,7 @@ pub struct EmbedThumbnail { width: Option, } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] struct EmbedVideo { url: Option, proxy_url: Option, @@ -163,13 +163,13 @@ struct EmbedVideo { width: Option, } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct EmbedProvider { name: Option, url: Option, } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct EmbedAuthor { name: String, url: Option, @@ -177,7 +177,7 @@ pub struct EmbedAuthor { proxy_icon_url: Option, } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct EmbedField { name: String, value: String, diff --git a/src/types/schema/channel.rs b/src/types/schema/channel.rs index dd62f88..354459c 100644 --- a/src/types/schema/channel.rs +++ b/src/types/schema/channel.rs @@ -1,6 +1,7 @@ use bitflags::bitflags; use serde::{Deserialize, Serialize}; +use crate::types::ChannelType; use crate::types::{entities::PermissionOverwrite, Snowflake}; #[derive(Debug, Deserialize, Serialize, Default, PartialEq, PartialOrd)] @@ -8,7 +9,7 @@ use crate::types::{entities::PermissionOverwrite, Snowflake}; pub struct ChannelCreateSchema { pub name: String, #[serde(rename = "type")] - pub channel_type: Option, + pub channel_type: Option, pub topic: Option, pub icon: Option, pub bitrate: Option, diff --git a/src/types/schema/message.rs b/src/types/schema/message.rs index e1abdf7..e0ee804 100644 --- a/src/types/schema/message.rs +++ b/src/types/schema/message.rs @@ -3,8 +3,9 @@ use serde::{Deserialize, Serialize}; use crate::types::entities::{ AllowedMention, Component, Embed, MessageReference, PartialDiscordFileAttachment, }; +use crate::types::Snowflake; -#[derive(Debug, Default, Deserialize, Serialize)] +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub struct MessageSendSchema { #[serde(rename = "type")] @@ -19,3 +20,79 @@ pub struct MessageSendSchema { pub sticker_ids: Option>, pub attachments: Option>, } + +pub enum MessageSearchEndpoint { + GuildChannel(Snowflake), + Channel(Snowflake), +} + +impl std::fmt::Display for MessageSearchEndpoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MessageSearchEndpoint::Channel(id) => { + write!(f, "channels/{}", &id.to_string()) + } + MessageSearchEndpoint::GuildChannel(id) => { + write!(f, "guilds/{}", &id.to_string()) + } + } + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +/// Represents a Message Search Query JSON Body. +/// The `channel_id` field is not applicable when using the `GET /channels/{channel.id}/messages/search` endpoint. +/// +/// # Reference: +/// See +pub struct MessageSearchQuery { + pub attachment_extension: Option>, + pub attachment_filename: Option>, + pub author_id: Option>, + pub author_type: Option>, + pub channel_id: Option>, + pub command_id: Option>, + pub content: Option, + pub embed_provider: Option>, + pub embed_type: Option>, + pub has: Option>, + pub include_nsfw: Option, + pub limit: Option, + pub link_hostname: Option>, + pub max_id: Option, + pub mention_everyone: Option, + pub mentions: Option>, + pub min_id: Option, + pub offset: Option, + pub pinned: Option, + pub sort_by: Option, + pub sort_order: Option, +} + +impl std::default::Default for MessageSearchQuery { + fn default() -> Self { + Self { + attachment_extension: Default::default(), + attachment_filename: Default::default(), + author_id: Default::default(), + author_type: Default::default(), + channel_id: Default::default(), + command_id: Default::default(), + content: Default::default(), + embed_provider: Default::default(), + embed_type: Default::default(), + has: Default::default(), + include_nsfw: Some(false), + limit: Some(25), + link_hostname: Default::default(), + max_id: Default::default(), + mention_everyone: Default::default(), + mentions: Default::default(), + min_id: Default::default(), + offset: Some(0), + pinned: Default::default(), + sort_by: Default::default(), + sort_order: Default::default(), + } + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 029df2d..2dc0718 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -73,7 +73,7 @@ pub(crate) async fn setup() -> TestBundle { }; let channel_create_schema = ChannelCreateSchema { name: "testchannel".to_string(), - channel_type: Some(0), + channel_type: Some(chorus::types::ChannelType::GuildText), topic: None, icon: None, bitrate: None, diff --git a/tests/messages.rs b/tests/messages.rs index 417ca54..2854b1d 100644 --- a/tests/messages.rs +++ b/tests/messages.rs @@ -1,7 +1,7 @@ use std::fs::File; use std::io::{BufReader, Read}; -use chorus::types; +use chorus::types::{self, Guild, MessageSearchQuery}; mod common; @@ -53,3 +53,49 @@ async fn send_message_attachment() { bundle.user.send_message(message, channel.id).await.unwrap(); common::teardown(bundle).await } + +#[tokio::test] +async fn search_messages() { + let f = File::open("./README.md").unwrap(); + let mut reader = BufReader::new(f); + let mut buffer = Vec::new(); + let mut bundle = common::setup().await; + + reader.read_to_end(&mut buffer).unwrap(); + + let attachment = types::PartialDiscordFileAttachment { + id: None, + filename: "README.md".to_string(), + description: None, + content_type: None, + size: None, + url: None, + proxy_url: None, + width: None, + height: None, + ephemeral: None, + duration_secs: None, + waveform: None, + content: buffer, + }; + + let message = types::MessageSendSchema { + content: Some("trans rights now".to_string()), + attachments: Some(vec![attachment.clone()]), + ..Default::default() + }; + let channel = bundle.channel.read().unwrap().clone(); + let vec_attach = vec![attachment.clone()]; + let _arg = Some(&vec_attach); + let message = bundle.user.send_message(message, channel.id).await.unwrap(); + let query = MessageSearchQuery { + author_id: Some(Vec::from([bundle.user.object.read().unwrap().id])), + ..Default::default() + }; + let guild_id = bundle.guild.read().unwrap().id; + let query_result = Guild::search_messages(guild_id, query, &mut bundle.user) + .await + .unwrap(); + assert!(!query_result.is_empty()); + assert_eq!(query_result.get(0).unwrap().id, message.id); +}