diff --git a/Cargo.lock b/Cargo.lock index 15ca9fac5..9d23d0565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1548,9 +1548,9 @@ checksum = "90f97a5f38dd3ccfbe7aa80f4a0c00930f21b922c74195be0201c51028f22dcf" [[package]] name = "indexmap" -version = "1.9.1" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg 1.1.0", "hashbrown 0.12.1", @@ -2883,6 +2883,7 @@ dependencies = [ "authifier", "bson", "futures", + "indexmap", "iso8601-timestamp 0.2.11", "log", "mongodb", @@ -2949,6 +2950,7 @@ dependencies = [ name = "revolt-models" version = "0.6.5" dependencies = [ + "indexmap", "iso8601-timestamp 0.2.11", "revolt-permissions", "revolt_optional_struct", diff --git a/crates/core/database/Cargo.toml b/crates/core/database/Cargo.toml index 791de5413..f4880144f 100644 --- a/crates/core/database/Cargo.toml +++ b/crates/core/database/Cargo.toml @@ -3,29 +3,32 @@ name = "revolt-database" version = "0.6.5" edition = "2021" license = "AGPL-3.0-or-later" -authors = [ "Paul Makles " ] +authors = ["Paul Makles "] description = "Revolt Backend: Database Implementation" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] # Databases -mongodb = [ "dep:mongodb", "bson" ] +mongodb = ["dep:mongodb", "bson"] # ... Other -async-std-runtime = [ "async-std" ] -rocket-impl = [ "rocket", "schemars" ] -redis-is-patched = [ "revolt-presence/redis-is-patched" ] +async-std-runtime = ["async-std"] +rocket-impl = ["rocket", "schemars"] +redis-is-patched = ["revolt-presence/redis-is-patched"] # Default Features -default = [ "mongodb", "async-std-runtime" ] +default = ["mongodb", "async-std-runtime"] [dependencies] # Core revolt-result = { version = "0.6.5", path = "../result" } revolt-models = { version = "0.6.5", path = "../models" } revolt-presence = { version = "0.6.5", path = "../presence" } -revolt-permissions = { version = "0.6.5", path = "../permissions", features = [ "serde", "bson" ] } +revolt-permissions = { version = "0.6.5", path = "../permissions", features = [ + "serde", + "bson", +] } # Utility log = "0.4" @@ -33,6 +36,7 @@ rand = "0.8.5" ulid = "1.0.0" nanoid = "0.4.0" once_cell = "1.17" +indexmap = "1.9.1" # Serialisation serde_json = "1" @@ -61,7 +65,9 @@ async-std = { version = "1.8.0", features = ["attributes"], optional = true } # Rocket Impl schemars = { version = "0.8.8", optional = true } -rocket = { version = "0.5.0-rc.2", default-features = false, features = ["json"], optional = true } +rocket = { version = "0.5.0-rc.2", default-features = false, features = [ + "json", +], optional = true } # Authifier authifier = { version = "1.0" } diff --git a/crates/core/database/src/models/messages/mod.rs b/crates/core/database/src/models/messages/mod.rs new file mode 100644 index 000000000..4d801b73e --- /dev/null +++ b/crates/core/database/src/models/messages/mod.rs @@ -0,0 +1,5 @@ +mod model; +mod ops; + +pub use model::*; +pub use ops::*; diff --git a/crates/core/database/src/models/messages/model.rs b/crates/core/database/src/models/messages/model.rs new file mode 100644 index 000000000..50e43fc35 --- /dev/null +++ b/crates/core/database/src/models/messages/model.rs @@ -0,0 +1,212 @@ +use indexmap::{IndexMap, IndexSet}; +use iso8601_timestamp::Timestamp; +use revolt_models::v0::{Embed, MessageSort, MessageWebhook}; + +use crate::File; + +auto_derived_partial!( + /// Message + pub struct Message { + /// Unique Id + #[serde(rename = "_id")] + pub id: String, + /// Unique value generated by client sending this message + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, + /// Id of the channel this message was sent in + pub channel: String, + /// Id of the user or webhook that sent this message + pub author: String, + /// The webhook that sent this message + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook: Option, + /// Message content + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + /// System message + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + /// Array of attachments + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option>, + /// Time at which this message was last edited + #[serde(skip_serializing_if = "Option::is_none")] + pub edited: Option, + /// Attached embeds to this message + #[serde(skip_serializing_if = "Option::is_none")] + pub embeds: Option>, + /// Array of user ids mentioned in this message + #[serde(skip_serializing_if = "Option::is_none")] + pub mentions: Option>, + /// Array of message ids this message is replying to + #[serde(skip_serializing_if = "Option::is_none")] + pub replies: Option>, + /// Hashmap of emoji IDs to array of user IDs + #[serde(skip_serializing_if = "IndexMap::is_empty", default)] + pub reactions: IndexMap>, + /// Information about how this message should be interacted with + #[serde(skip_serializing_if = "Interactions::is_default", default)] + pub interactions: Interactions, + /// Name and / or avatar overrides for this message + #[serde(skip_serializing_if = "Option::is_none")] + pub masquerade: Option, + }, + "PartialMessage" +); + +auto_derived!( + /// System Event + #[serde(tag = "type")] + pub enum SystemMessage { + #[serde(rename = "text")] + Text { content: String }, + #[serde(rename = "user_added")] + UserAdded { id: String, by: String }, + #[serde(rename = "user_remove")] + UserRemove { id: String, by: String }, + #[serde(rename = "user_joined")] + UserJoined { id: String }, + #[serde(rename = "user_left")] + UserLeft { id: String }, + #[serde(rename = "user_kicked")] + UserKicked { id: String }, + #[serde(rename = "user_banned")] + UserBanned { id: String }, + #[serde(rename = "channel_renamed")] + ChannelRenamed { name: String, by: String }, + #[serde(rename = "channel_description_changed")] + ChannelDescriptionChanged { by: String }, + #[serde(rename = "channel_icon_changed")] + ChannelIconChanged { by: String }, + #[serde(rename = "channel_ownership_changed")] + ChannelOwnershipChanged { from: String, to: String }, + } + + /// Name and / or avatar override information + pub struct Masquerade { + /// Replace the display name shown on this message + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Replace the avatar shown on this message (URL to image file) + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar: Option, + /// Replace the display role colour shown on this message + /// + /// Must have `ManageRole` permission to use + #[serde(skip_serializing_if = "Option::is_none")] + pub colour: Option, + } + + /// Information to guide interactions on this message + #[derive(Default)] + pub struct Interactions { + /// Reactions which should always appear and be distinct + #[serde(skip_serializing_if = "Option::is_none", default)] + pub reactions: Option>, + /// Whether reactions should be restricted to the given list + /// + /// Can only be set to true if reactions list is of at least length 1 + #[serde(skip_serializing_if = "crate::if_false", default)] + pub restrict_reactions: bool, + } + + /// Appended Information + pub struct AppendMessage { + /// Additional embeds to include in this message + #[serde(skip_serializing_if = "Option::is_none")] + pub embeds: Option>, + } + + /// Message Time Period + /// + /// Filter and sort messages by time + #[serde(untagged)] + pub enum MessageTimePeriod { + Relative { + /// Message id to search around + /// + /// Specifying 'nearby' ignores 'before', 'after' and 'sort'. + /// It will also take half of limit rounded as the limits to each side. + /// It also fetches the message ID specified. + nearby: String, + }, + Absolute { + /// Message id before which messages should be fetched + before: Option, + /// Message id after which messages should be fetched + after: Option, + /// Message sort direction + sort: Option, + }, + } + + /// Message Filter + pub struct MessageFilter { + /// Parent channel ID + pub channel: Option, + /// Message author ID + pub author: Option, + /// Search query + pub query: Option, + } + + /// Message Query + pub struct MessageQuery { + /// Maximum number of messages to fetch + /// + /// For fetching nearby messages, this is \`(limit + 1)\`. + pub limit: Option, + /// Filter to apply + #[serde(flatten)] + pub filter: MessageFilter, + /// Time period to fetch + #[serde(flatten)] + pub time_period: MessageTimePeriod, + } +); + +#[allow(clippy::disallowed_methods)] +impl Message {} + +impl Interactions { + /// Validate interactions info is correct + /* pub async fn validate( + &self, + db: &Database, + permissions: &mut PermissionCalculator<'_>, + ) -> Result<()> { + if let Some(reactions) = &self.reactions { + permissions.throw_permission(db, Permission::React).await?; + + if reactions.len() > 20 { + return Err(Error::InvalidOperation); + } + + for reaction in reactions { + if !Emoji::can_use(db, reaction).await? { + return Err(Error::InvalidOperation); + } + } + } + + Ok(()) + }*/ + + /// Check if we can use a given emoji to react + pub fn can_use(&self, emoji: &str) -> bool { + if self.restrict_reactions { + if let Some(reactions) = &self.reactions { + reactions.contains(emoji) + } else { + false + } + } else { + true + } + } + + /// Check if default initialisation of fields + pub fn is_default(&self) -> bool { + !self.restrict_reactions && self.reactions.is_none() + } +} diff --git a/crates/core/database/src/models/messages/ops.rs b/crates/core/database/src/models/messages/ops.rs new file mode 100644 index 000000000..875b3454d --- /dev/null +++ b/crates/core/database/src/models/messages/ops.rs @@ -0,0 +1,39 @@ +use revolt_result::Result; + +use crate::{AppendMessage, Message, MessageQuery, PartialMessage}; + +// mod mongodb; +// mod reference; + +#[async_trait] +pub trait AbstractMessages: Sync + Send { + /// Insert a new message into the database + async fn insert_message(&self, message: &Message) -> Result<()>; + + /// Fetch a message by its id + async fn fetch_message(&self, id: &str) -> Result; + + /// Fetch multiple messages by given query + async fn fetch_messages(&self, query: MessageQuery) -> Result>; + + /// Update a given message with new information + async fn update_message(&self, id: &str, message: &PartialMessage) -> Result<()>; + + /// Append information to a given message + async fn append_message(&self, id: &str, append: &AppendMessage) -> Result<()>; + + /// Add a new reaction to a message + async fn add_reaction(&self, id: &str, emoji: &str, user: &str) -> Result<()>; + + /// Remove a reaction from a message + async fn remove_reaction(&self, id: &str, emoji: &str, user: &str) -> Result<()>; + + /// Remove reaction from a message + async fn clear_reaction(&self, id: &str, emoji: &str) -> Result<()>; + + /// Delete a message from the database by its id + async fn delete_message(&self, id: &str) -> Result<()>; + + /// Delete messages from a channel by their ids and corresponding channel id + async fn delete_messages(&self, channel: &str, ids: Vec) -> Result<()>; +} diff --git a/crates/core/database/src/models/messages/ops/mongodb.rs b/crates/core/database/src/models/messages/ops/mongodb.rs new file mode 100644 index 000000000..882cc8eca --- /dev/null +++ b/crates/core/database/src/models/messages/ops/mongodb.rs @@ -0,0 +1,70 @@ +use bson::Document; +use revolt_result::Result; + +use crate::Emoji; +use crate::MongoDb; + +use super::AbstractEmojis; + +static COL: &str = "emojis"; + +#[async_trait] +impl AbstractEmojis for MongoDb { + /// Insert emoji into database. + async fn insert_emoji(&self, emoji: &Emoji) -> Result<()> { + query!(self, insert_one, COL, &emoji).map(|_| ()) + } + + /// Fetch an emoji by its id + async fn fetch_emoji(&self, id: &str) -> Result { + query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch emoji by their parent id + async fn fetch_emoji_by_parent_id(&self, parent_id: &str) -> Result> { + query!( + self, + find_one, + COL, + doc! { + "parent.id": parent_id + } + )? + .ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch emoji by their parent ids + async fn fetch_emoji_by_parent_ids(&self, parent_ids: &[String]) -> Result> { + query!( + self, + find, + COL, + doc! { + "parent.id": { + "$in": parent_ids + } + } + ) + } + + /// Detach an emoji by its id + async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> { + self.col::(COL) + .update_one( + doc! { + "_id": &emoji.id + }, + doc! { + "$set": { + "parent": { + "type": "Detached" + } + } + }, + None, + ) + .await + .map(|_| ()) + .map_err(|_| create_database_error!("update_one", COL)) + } +} diff --git a/crates/core/database/src/models/messages/ops/reference.rs b/crates/core/database/src/models/messages/ops/reference.rs new file mode 100644 index 000000000..2f0c2a2df --- /dev/null +++ b/crates/core/database/src/models/messages/ops/reference.rs @@ -0,0 +1,67 @@ +use revolt_result::Result; + +use crate::Emoji; +use crate::EmojiParent; +use crate::ReferenceDb; + +use super::AbstractEmojis; + +#[async_trait] +impl AbstractEmojis for ReferenceDb { + /// Insert emoji into database. + async fn insert_emoji(&self, emoji: &Emoji) -> Result<()> { + let mut emojis = self.emojis.lock().await; + if emojis.contains_key(&emoji.id) { + Err(create_database_error!("insert", "emoji")) + } else { + emojis.insert(emoji.id.to_string(), emoji.clone()); + Ok(()) + } + } + + /// Fetch an emoji by its id + async fn fetch_emoji(&self, id: &str) -> Result { + let emojis = self.emojis.lock().await; + emojis + .get(id) + .cloned() + .ok_or_else(|| create_error!(NotFound)) + } + + /// Fetch emoji by their parent id + async fn fetch_emoji_by_parent_id(&self, parent_id: &str) -> Result> { + let emojis = self.emojis.lock().await; + Ok(emojis + .values() + .filter(|emoji| match &emoji.parent { + EmojiParent::Server { id } => id == parent_id, + _ => false, + }) + .cloned() + .collect()) + } + + /// Fetch emoji by their parent ids + async fn fetch_emoji_by_parent_ids(&self, parent_ids: &[String]) -> Result> { + let emojis = self.emojis.lock().await; + Ok(emojis + .values() + .filter(|emoji| match &emoji.parent { + EmojiParent::Server { id } => parent_ids.contains(id), + _ => false, + }) + .cloned() + .collect()) + } + + /// Detach an emoji by its id + async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> { + let mut emojis = self.emojis.lock().await; + if let Some(bot) = emojis.get_mut(&emoji.id) { + bot.parent = EmojiParent::Detached; + Ok(()) + } else { + Err(create_error!(NotFound)) + } + } +} diff --git a/crates/core/database/src/models/mod.rs b/crates/core/database/src/models/mod.rs index 2609d557c..e5271dfb0 100644 --- a/crates/core/database/src/models/mod.rs +++ b/crates/core/database/src/models/mod.rs @@ -6,6 +6,7 @@ mod channel_webhooks; mod channels; mod emojis; mod files; +mod messages; mod ratelimit_events; mod server_bans; mod server_members; @@ -21,6 +22,7 @@ pub use channel_webhooks::*; pub use channels::*; pub use emojis::*; pub use files::*; +pub use messages::*; pub use ratelimit_events::*; pub use server_bans::*; pub use server_members::*; diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index 9eb6b01c1..f329e345f 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -294,6 +294,95 @@ impl From for Metadata { } } +impl From for Message { + fn from(value: crate::Message) -> Self { + Message { + id: value.id, + nonce: value.nonce, + channel: value.channel, + author: value.author, + webhook: value.webhook, + content: value.content, + system: value.system.map(|system| system.into()), + attachments: value + .attachments + .map(|v| v.into_iter().map(|f| f.into()).collect()), + edited: value.edited, + embeds: value.embeds, + mentions: value.mentions, + replies: value.replies, + reactions: value.reactions, + interactions: value.interactions.into(), + masquerade: value.masquerade.map(|masq| masq.into()), + } + } +} + +impl From for PartialMessage { + fn from(value: crate::PartialMessage) -> Self { + PartialMessage { + id: value.id, + nonce: value.nonce, + channel: value.channel, + author: value.author, + webhook: value.webhook, + content: value.content, + system: value.system.map(|system| system.into()), + attachments: value + .attachments + .map(|v| v.into_iter().map(|f| f.into()).collect()), + edited: value.edited, + embeds: value.embeds, + mentions: value.mentions, + replies: value.replies, + reactions: value.reactions, + interactions: value.interactions.map(|interactions| interactions.into()), + masquerade: value.masquerade.map(|masq| masq.into()), + } + } +} + +impl From for SystemMessage { + fn from(value: crate::SystemMessage) -> Self { + match value { + crate::SystemMessage::ChannelDescriptionChanged { by } => { + Self::ChannelDescriptionChanged { by } + } + crate::SystemMessage::ChannelIconChanged { by } => Self::ChannelIconChanged { by }, + crate::SystemMessage::ChannelOwnershipChanged { from, to } => { + Self::ChannelOwnershipChanged { from, to } + } + crate::SystemMessage::ChannelRenamed { name, by } => Self::ChannelRenamed { name, by }, + crate::SystemMessage::Text { content } => Self::Text { content }, + crate::SystemMessage::UserAdded { id, by } => Self::UserAdded { id, by }, + crate::SystemMessage::UserBanned { id } => Self::UserBanned { id }, + crate::SystemMessage::UserJoined { id } => Self::UserJoined { id }, + crate::SystemMessage::UserKicked { id } => Self::UserKicked { id }, + crate::SystemMessage::UserLeft { id } => Self::UserLeft { id }, + crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by }, + } + } +} + +impl From for Interactions { + fn from(value: crate::Interactions) -> Self { + Interactions { + reactions: value.reactions, + restrict_reactions: value.restrict_reactions, + } + } +} + +impl From for Masquerade { + fn from(value: crate::Masquerade) -> Self { + Masquerade { + name: value.name, + avatar: value.avatar, + colour: value.colour, + } + } +} + impl From for ServerBan { fn from(value: crate::ServerBan) -> Self { ServerBan { diff --git a/crates/core/models/Cargo.toml b/crates/core/models/Cargo.toml index 486c6535b..992f524f1 100644 --- a/crates/core/models/Cargo.toml +++ b/crates/core/models/Cargo.toml @@ -21,6 +21,7 @@ default = ["serde", "partials"] revolt-permissions = { version = "0.6.5", path = "../permissions" } # Serialisation +indexmap = "1.9.3" revolt_optional_struct = { version = "0.2.0", optional = true } serde = { version = "1", features = ["derive"], optional = true } iso8601-timestamp = { version = "0.2.11", features = ["schema", "bson"] } diff --git a/crates/core/models/src/v0/embeds.rs b/crates/core/models/src/v0/embeds.rs new file mode 100644 index 000000000..faeeeb3da --- /dev/null +++ b/crates/core/models/src/v0/embeds.rs @@ -0,0 +1,160 @@ +use super::File; + +auto_derived!( + /// Image positioning and size + pub enum ImageSize { + /// Show large preview at the bottom of the embed + Large, + /// Show small preview to the side of the embed + Preview, + } + + /// Image + pub struct Image { + /// URL to the original image + pub url: String, + /// Width of the image + pub width: isize, + /// Height of the image + pub height: isize, + /// Positioning and size + pub size: ImageSize, + } + + /// Video + pub struct Video { + /// URL to the original video + pub url: String, + /// Width of the video + pub width: isize, + /// Height of the video + pub height: isize, + } + + /// Type of remote Twitch content + pub enum TwitchType { + Channel, + Video, + Clip, + } + + /// Type of remote Lightspeed.tv content + pub enum LightspeedType { + Channel, + } + + /// Type of remote Bandcamp content + pub enum BandcampType { + Album, + Track, + } + + /// Information about special remote content + #[serde(tag = "type")] + pub enum Special { + /// No remote content + None, + /// Content hint that this contains a GIF + /// + /// Use metadata to find video or image to play + GIF, + /// YouTube video + YouTube { + id: String, + + #[serde(skip_serializing_if = "Option::is_none")] + timestamp: Option, + }, + /// Lightspeed.tv stream + Lightspeed { + content_type: LightspeedType, + id: String, + }, + /// Twitch stream or clip + Twitch { + content_type: TwitchType, + id: String, + }, + /// Spotify track + Spotify { content_type: String, id: String }, + /// Soundcloud track + Soundcloud, + /// Bandcamp track + Bandcamp { + content_type: BandcampType, + id: String, + }, + /// Streamable Video + Streamable { id: String }, + } + + /// Website metadata + pub struct WebsiteMetadata { + /// Direct URL to web page + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + /// Original direct URL + #[serde(skip_serializing_if = "Option::is_none")] + original_url: Option, + /// Remote content + #[serde(skip_serializing_if = "Option::is_none")] + special: Option, + + /// Title of website + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, + /// Description of website + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + /// Embedded image + #[serde(skip_serializing_if = "Option::is_none")] + image: Option, + /// Embedded video + #[serde(skip_serializing_if = "Option::is_none")] + video: Option