diff --git a/crates/delta/src/routes/channels/message_send.rs b/crates/delta/src/routes/channels/message_send.rs index ee9f2bcf..9645fffc 100644 --- a/crates/delta/src/routes/channels/message_send.rs +++ b/crates/delta/src/routes/channels/message_send.rs @@ -1,8 +1,6 @@ -use std::collections::HashSet; - use revolt_quark::{ models::{ - message::{Interactions, Masquerade, Reply, SendableEmbed}, + message::DataMessageSend, Message, User, }, perms, @@ -10,45 +8,9 @@ use revolt_quark::{ Db, Error, Permission, Ref, Result, types::push::MessageAuthor, }; -use regex::Regex; use rocket::serde::json::Json; -use serde::{Deserialize, Serialize}; -use ulid::Ulid; use validator::Validate; -#[derive(Validate, Serialize, Deserialize, JsonSchema)] -pub struct DataMessageSend { - /// Unique token to prevent duplicate message sending - /// - /// **This is deprecated and replaced by `Idempotency-Key`!** - #[validate(length(min = 1, max = 64))] - pub nonce: Option, - - /// Message content to send - #[validate(length(min = 0, max = 2000))] - pub content: Option, - /// Attachments to include in message - #[validate(length(min = 1, max = 128))] - pub attachments: Option>, - /// Messages to reply to - pub replies: Option>, - /// Embeds to include in message - /// - /// Text embed content contributes to the content length cap - #[validate(length(min = 1, max = 10))] - pub embeds: Option>, - /// Masquerade to apply to this message - #[validate] - pub masquerade: Option, - /// Information about how this message should be interacted with - pub interactions: Option, -} - -lazy_static! { - // ignoring I L O and U is intentional - pub static ref RE_MENTION: Regex = Regex::new(r"<@([0-9A-HJKMNP-TV-Z]{26})>").unwrap(); -} - /// # Send Message /// /// Sends a message to the given channel. @@ -59,71 +21,22 @@ pub async fn message_send( user: User, target: Ref, data: Json, - mut idempotency: IdempotencyKey, + idempotency: IdempotencyKey, ) -> Result> { let data = data.into_inner(); data.validate() .map_err(|error| Error::FailedValidation { error })?; - // Validate Message is within reasonable length limits - Message::validate_sum(&data.content, &data.embeds)?; - - // Ensure the request is unique - idempotency.consume_nonce(data.nonce).await?; - // Ensure we have permissions to send a message let channel = target.as_channel(db).await?; + let mut permissions = perms(&user).channel(&channel); permissions .throw_permission_and_view_channel(db, Permission::SendMessage) .await?; - // Check the message is not empty - if (data.content.as_ref().map_or(true, |v| v.is_empty())) - && (data.attachments.as_ref().map_or(true, |v| v.is_empty())) - && (data.embeds.as_ref().map_or(true, |v| v.is_empty())) - { - return Err(Error::EmptyMessage); - } - - // Ensure restrict_reactions is not specified without reactions list - if let Some(interactions) = &data.interactions { - if interactions.restrict_reactions { - let disallowed = if let Some(list) = &interactions.reactions { - list.len() == 0 - } else { - true - }; - - if disallowed { - return Err(Error::InvalidProperty); - } - } - } - - // Start constructing the message - let message_id = Ulid::new().to_string(); - let mut message = Message { - id: message_id.clone(), - channel: channel.id().to_string(), - author: Some(user.id.clone()), - masquerade: data.masquerade, - interactions: data.interactions.unwrap_or_default(), - ..Default::default() - }; - - // 1. Parse mentions in message. - let mut mentions = HashSet::new(); - if let Some(content) = &data.content { - for capture in RE_MENTION.captures_iter(content) { - if let Some(mention) = capture.get(1) { - mentions.insert(mention.as_str().to_string()); - } - } - } - - // 2. Verify permissions for masquerade. - if let Some(masq) = &message.masquerade { + // Verify permissions for masquerade. + if let Some(masq) = &data.masquerade { permissions .throw_permission(db, Permission::Masquerade) .await?; @@ -135,97 +48,8 @@ pub async fn message_send( } } - // 3. Ensure interactions information is correct - message.interactions.validate(db, &mut permissions).await?; - - // 4. Verify replies are valid. - let mut replies = HashSet::new(); - if let Some(entries) = data.replies { - if entries.len() > 5 { - return Err(Error::TooManyReplies); - } - - for Reply { id, mention } in entries { - let message = Ref::from_unchecked(id).as_message(db).await?; - - if mention { - mentions.insert(message.author_id().to_owned()); - } - - replies.insert(message.id); - } - } - - if !mentions.is_empty() { - message.mentions.replace( - mentions - .into_iter() - .filter(|id| !user.has_blocked(id)) - .collect::>(), - ); - } - - if !replies.is_empty() { - message - .replies - .replace(replies.into_iter().collect::>()); - } - - // 5. Process included embeds. - let mut embeds = vec![]; - if let Some(sendable_embeds) = data.embeds { - for sendable_embed in sendable_embeds { - embeds.push(sendable_embed.into_embed(db, message_id.clone()).await?) - } - } - - if !embeds.is_empty() { - message.embeds.replace(embeds); - } - - // 6. Add attachments to message. - let mut attachments = vec![]; - if let Some(ids) = &data.attachments { - if !ids.is_empty() { - permissions - .throw_permission(db, Permission::UploadFiles) - .await?; - } - - // ! FIXME: move this to app config - if ids.len() > 5 { - return Err(Error::TooManyAttachments); - } - - for attachment_id in ids { - attachments.push( - db.find_and_use_attachment(attachment_id, "attachments", "message", &message_id) - .await?, - ); - } - } - - if !attachments.is_empty() { - message.attachments.replace(attachments); - } - - // 7. Set content - message.content = data.content; - - // 8. Pass-through nonce value for clients - message.nonce = Some(idempotency.into_key()); - - message.create(db, &channel, Some(MessageAuthor::User(&user))).await?; - - // Queue up a task for processing embeds - if let Some(content) = &message.content { - revolt_quark::tasks::process_embeds::queue( - channel.id().to_string(), - message.id.to_string(), - content.clone(), - ) - .await; - } + // Create the message + let message = channel.send_message(db, data, MessageAuthor::User(&user), idempotency).await?; Ok(Json(message)) } diff --git a/crates/delta/src/routes/webhooks/webhook_execute.rs b/crates/delta/src/routes/webhooks/webhook_execute.rs index 8d1e8fe3..99c7534e 100644 --- a/crates/delta/src/routes/webhooks/webhook_execute.rs +++ b/crates/delta/src/routes/webhooks/webhook_execute.rs @@ -1,9 +1,6 @@ -use std::collections::HashSet; - -use revolt_quark::{Db, Ref, Result, Error, models::{Message, message::Reply}, web::idempotency::IdempotencyKey, types::push::MessageAuthor}; +use revolt_quark::{Db, Ref, Result, Error, models::message::{Message, DataMessageSend}, web::idempotency::IdempotencyKey, types::push::MessageAuthor}; use rocket::serde::json::Json; -use ulid::Ulid; -use crate::routes::channels::message_send::{DataMessageSend, RE_MENTION}; + use validator::Validate; /// # Executes a webhook @@ -11,7 +8,7 @@ use validator::Validate; /// executes a webhook and sends a message #[openapi(tag = "Webhooks")] #[post("//", data="")] -pub async fn req(db: &Db, target: Ref, token: String, data: Json, mut idempotency: IdempotencyKey) -> Result> { +pub async fn req(db: &Db, target: Ref, token: String, data: Json, idempotency: IdempotencyKey) -> Result> { let data = data.into_inner(); data.validate() .map_err(|error| Error::FailedValidation { error })?; @@ -22,117 +19,8 @@ pub async fn req(db: &Db, target: Ref, token: String, data: Json 5 { - return Err(Error::TooManyReplies); - } - - for Reply { id, mention } in entries { - let message = Ref::from_unchecked(id).as_message(db).await?; - - if mention { - mentions.insert(message.author_id().to_owned()); - } - - replies.insert(message.id); - } - } - - if !mentions.is_empty() { - message.mentions.replace(mentions.into_iter().collect()); - } - - if !replies.is_empty() { - message - .replies - .replace(replies.into_iter().collect::>()); - } - - // 5. Process included embeds. - let mut embeds = vec![]; - if let Some(sendable_embeds) = data.embeds { - for sendable_embed in sendable_embeds { - embeds.push(sendable_embed.into_embed(db, message_id.clone()).await?) - } - } - - if !embeds.is_empty() { - message.embeds.replace(embeds); - } - - // 6. Add attachments to message. - let mut attachments = vec![]; - if let Some(ids) = &data.attachments { - // ! FIXME: move this to app config - if ids.len() > 5 { - return Err(Error::TooManyAttachments); - } - - for attachment_id in ids { - attachments.push( - db.find_and_use_attachment(attachment_id, "attachments", "message", &message_id) - .await?, - ); - } - } - - if !attachments.is_empty() { - message.attachments.replace(attachments); - } - - // 7. Set content - message.content = data.content; - - // 8. Pass-through nonce value for clients - message.nonce = Some(idempotency.into_key()); - - message.create(db, &channel, Some(MessageAuthor::Webhook(&webhook))).await?; - - // Queue up a task for processing embeds - if let Some(content) = &message.content { - revolt_quark::tasks::process_embeds::queue( - channel.id().to_string(), - message.id.to_string(), - content.clone(), - ) - .await; - } + let message = channel.send_message(db, data, MessageAuthor::Webhook(&webhook), idempotency).await?; Ok(Json(message)) - } diff --git a/crates/quark/src/impl/generic/channels/channel.rs b/crates/quark/src/impl/generic/channels/channel.rs index 65201ea8..e89d81e9 100644 --- a/crates/quark/src/impl/generic/channels/channel.rs +++ b/crates/quark/src/impl/generic/channels/channel.rs @@ -1,12 +1,16 @@ +use std::collections::HashSet; + +use ulid::Ulid; + use crate::{ events::client::EventV1, models::{ channel::{FieldsChannel, PartialChannel}, - message::SystemMessage, + message::{SystemMessage, Message, DataMessageSend, Reply, RE_MENTION}, Channel, }, - tasks::ack::AckEvent, - Database, Error, OverrideField, Result, + tasks::{ack::AckEvent, process_embeds}, + Database, Error, OverrideField, Result, types::push::MessageAuthor, web::idempotency::IdempotencyKey, Ref, }; impl Channel { @@ -394,4 +398,139 @@ impl Channel { _ => Err(Error::InvalidOperation), } } + + /// Creates a message in a channel + pub async fn send_message(&self, db: &Database, data: DataMessageSend, author: MessageAuthor<'_>, mut idempotency: IdempotencyKey) -> Result { + Message::validate_sum(&data.content, &data.embeds)?; + + idempotency.consume_nonce(data.nonce).await?; + + // Check the message is not empty + if (data.content.as_ref().map_or(true, |v| v.is_empty())) + && (data.attachments.as_ref().map_or(true, |v| v.is_empty())) + && (data.embeds.as_ref().map_or(true, |v| v.is_empty())) + { + return Err(Error::EmptyMessage); + } + + // Ensure restrict_reactions is not specified without reactions list + if let Some(interactions) = &data.interactions { + if interactions.restrict_reactions { + let disallowed = if let Some(list) = &interactions.reactions { + list.is_empty() + } else { + true + }; + + if disallowed { + return Err(Error::InvalidProperty); + } + } + } + + // Start constructing the message + let message_id = Ulid::new().to_string(); + let mut message = Message { + id: message_id.clone(), + channel: self.id().to_string(), + masquerade: data.masquerade, + interactions: data.interactions.unwrap_or_default(), + ..Default::default() + }; + + match &author { + MessageAuthor::User(user) => message.author = Some(user.id.clone()), + MessageAuthor::Webhook(webhook) => message.webhook = Some(webhook.id.clone()), + } + + // Parse mentions in message. + let mut mentions = HashSet::new(); + if let Some(content) = &data.content { + for capture in RE_MENTION.captures_iter(content) { + if let Some(mention) = capture.get(1) { + mentions.insert(mention.as_str().to_string()); + } + } + } + + // Verify replies are valid. + let mut replies = HashSet::new(); + if let Some(entries) = data.replies { + if entries.len() > 5 { + return Err(Error::TooManyReplies); + } + + for Reply { id, mention } in entries { + let message = Ref::from_unchecked(id).as_message(db).await?; + + if mention { + mentions.insert(message.author_id().to_owned()); + } + + replies.insert(message.id); + } + } + + if !mentions.is_empty() { + message.mentions.replace(mentions.into_iter().collect()); + } + + if !replies.is_empty() { + message + .replies + .replace(replies.into_iter().collect::>()); + } + + // Process included embeds. + let mut embeds = vec![]; + if let Some(sendable_embeds) = data.embeds { + for sendable_embed in sendable_embeds { + embeds.push(sendable_embed.into_embed(db, message_id.clone()).await?) + } + } + + if !embeds.is_empty() { + message.embeds.replace(embeds); + } + + // Add attachments to message. + let mut attachments = vec![]; + if let Some(ids) = &data.attachments { + // ! FIXME: move this to app config + if ids.len() > 5 { + return Err(Error::TooManyAttachments); + } + + for attachment_id in ids { + attachments.push( + db.find_and_use_attachment(attachment_id, "attachments", "message", &message_id) + .await?, + ); + } + } + + if !attachments.is_empty() { + message.attachments.replace(attachments); + } + + // Set content + message.content = data.content; + + // Pass-through nonce value for clients + message.nonce = Some(idempotency.into_key()); + + message.create(db, self, Some(author)).await?; + + // Queue up a task for processing embeds + if let Some(content) = &message.content { + process_embeds::queue( + self.id().to_string(), + message.id.to_string(), + content.clone(), + ) + .await; + } + + Ok(message) + } } diff --git a/crates/quark/src/models/channels/message.rs b/crates/quark/src/models/channels/message.rs index 4b82adb9..167732d5 100644 --- a/crates/quark/src/models/channels/message.rs +++ b/crates/quark/src/models/channels/message.rs @@ -2,6 +2,7 @@ use crate::util::regex::RE_COLOUR; use indexmap::{IndexMap, IndexSet}; use iso8601_timestamp::Timestamp; +use regex::Regex; use serde::{Deserialize, Serialize}; use validator::Validate; @@ -205,3 +206,36 @@ pub struct AppendMessage { #[serde(skip_serializing_if = "Option::is_none")] pub embeds: Option>, } + +#[derive(Validate, Serialize, Deserialize, JsonSchema)] +pub struct DataMessageSend { + /// Unique token to prevent duplicate message sending + /// + /// **This is deprecated and replaced by `Idempotency-Key`!** + #[validate(length(min = 1, max = 64))] + pub nonce: Option, + + /// Message content to send + #[validate(length(min = 0, max = 2000))] + pub content: Option, + /// Attachments to include in message + #[validate(length(min = 1, max = 128))] + pub attachments: Option>, + /// Messages to reply to + pub replies: Option>, + /// Embeds to include in message + /// + /// Text embed content contributes to the content length cap + #[validate(length(min = 1, max = 10))] + pub embeds: Option>, + /// Masquerade to apply to this message + #[validate] + pub masquerade: Option, + /// Information about how this message should be interacted with + pub interactions: Option, +} + +lazy_static! { + // ignoring I L O and U is intentional + pub static ref RE_MENTION: Regex = Regex::new(r"<@([0-9A-HJKMNP-TV-Z]{26})>").unwrap(); +}