diff --git a/src/database/migrations/0001_init.cql b/src/database/migrations/0001_init.cql index c292854..aaf64e0 100644 --- a/src/database/migrations/0001_init.cql +++ b/src/database/migrations/0001_init.cql @@ -6,6 +6,23 @@ CREATE KEYSPACE IF NOT EXISTS payphone 'enabled': false }; +CREATE TYPE IF NOT EXISTS payphone.message_metadata ( + call_id BIGINT, + author_id BIGINT, + author_name TEXT, + author_avatar_url TEXT, + author_mask ASCII, + content TEXT, + attachments LIST, + sticker BIGINT, + propagation_message_id BIGINT, + propagation_channel_id BIGINT, + replying_to_id BIGINT, + is_deleted BOOLEAN, + is_edited BOOLEAN, + is_censored BOOLEAN +); + CREATE TABLE IF NOT EXISTS payphone.users ( user_id BIGINT PRIMARY KEY, @@ -64,25 +81,7 @@ CREATE TABLE IF NOT EXISTS payphone.participants ( CREATE TABLE IF NOT EXISTS payphone.messages ( call_id BIGINT, message_id BIGINT, - author_id BIGINT, - - author_name TEXT, - author_avatar_url TEXT, - author_mask ASCII, - - content TEXT, - attachments LIST, - sticker BIGINT, - - propagation_message_id BIGINT, - propagation_channel_id BIGINT, - - replying_to_id BIGINT, - - is_deleted BOOLEAN, - is_edited BOOLEAN, - is_censored BOOLEAN, - + message_metadata FROZEN, PRIMARY KEY (call_id, message_id) ); @@ -95,61 +94,28 @@ CREATE TABLE IF NOT EXISTS payphone.servers ( CREATE TABLE IF NOT EXISTS payphone.reports ( report_id BIGINT, - - message_author_id BIGINT, - message_author_name TEXT, - message_author_avatar_url TEXT, - message_author_mask ASCII, - - message_id BIGINT, - message_content TEXT, - message_attachments LIST, - message_sticker BIGINT, - - message_replying_to_id BIGINT, - message_replying_to_content TEXT, - message_replying_to_attachments LIST, - message_replying_to_sticker BIGINT, - + message FROZEN, created_at TIMESTAMP, reviewed_at TIMESTAMP, - created_by_ids SET, - reviewed_by_id BIGINT, resolution ASCII, - - PRIMARY KEY (message_author_id, report_id) + PRIMARY KEY (report_id) ); CREATE TABLE IF NOT EXISTS payphone.user_incidents ( user_id BIGINT, incident_id BIGINT, - -- ban, warning type ASCII, - call_id BIGINT, report_id BIGINT, - - message_id BIGINT, - message_content TEXT, - message_attachments LIST, - message_sticker BIGINT, - - message_replying_to_id BIGINT, - message_replying_to_content TEXT, - message_replying_to_attachments LIST, - message_replying_to_sticker BIGINT, - + message FROZEN, reason TEXT, - created_by_id BIGINT, - created_at TIMESTAMP, expires_at TIMESTAMP, duration ASCII, - PRIMARY KEY (user_id) ); diff --git a/src/database/models.rs b/src/database/models.rs index 5c3834b..d41e9b3 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -1,3 +1,21 @@ +use scylla::{SerializeValue, DeserializeValue}; +#[derive(Debug, Clone, SerializeValue, DeserializeValue)] +pub struct MessageMetadata { + pub call_id: i64, + pub author_id: i64, + pub author_name: Option, + pub author_avatar_url: Option, + pub author_mask: Option, + pub content: Option, + pub attachments: Option>, + pub sticker: Option, + pub propagation_message_id: Option, + pub propagation_channel_id: Option, + pub replying_to_id: Option, + pub is_deleted: Option, + pub is_edited: Option, + pub is_censored: Option, +} use scylla::{ client::session::Session, deserialize::row::DeserializeRow, @@ -190,6 +208,89 @@ impl From for User { } } +// --- BEGIN: Payphone Message and Report Models --- + +#[derive(Debug, Clone)] +pub struct Message { + pub call_id: u64, + pub message_id: u64, + pub message_metadata: MessageMetadata, +} + +impl Message { + pub async fn get_by_id(call_id: u64, message_id: u64, session: &Session) -> Result, DatabaseError> { + let call_id = u64_to_i64(call_id); + let message_id = u64_to_i64(message_id); + let result = session + .query_unpaged( + r#" + SELECT call_id, message_id, message_metadata + FROM payphone.messages + WHERE call_id = ? AND message_id = ? + LIMIT 1; + "#, + (call_id, message_id), + ) + .await + .map_err(DatabaseError::QueryError)? + .into_rows_result() + .map_err(|_| DatabaseError::ResultNotRows)?; + match result.single_row::<(i64, i64, MessageMetadata)>() { + Ok(row) => Ok(Some(Self { + call_id: i64_to_u64(row.0), + message_id: i64_to_u64(row.1), + message_metadata: row.2, + })), + Err(error) => match error { + SingleRowError::DeserializationFailed(_) | SingleRowError::TypeCheckFailed(_) => Err(DatabaseError::DeserializationFailed), + SingleRowError::UnexpectedRowCount(_) => Ok(None), + }, + } + } +} + +#[derive(Debug, Clone)] +pub struct Report { + pub report_id: u64, + pub message: MessageMetadata, + pub created_at: OffsetDateTime, + pub reviewed_at: Option, + pub created_by_ids: Vec, + pub reviewed_by_id: Option, + pub resolution: Option, +} + +impl Report { + pub async fn insert(&self, session: &Session) -> Result<(), DatabaseError> { + session + .query_unpaged( + r#" + INSERT INTO payphone.reports ( + report_id, + message, + created_at, + reviewed_at, + created_by_ids, + reviewed_by_id, + resolution + ) VALUES (?, ?, ?, ?, ?, ?, ?); + "#, + ( + u64_to_i64(self.report_id), + &self.message, + self.created_at, + self.reviewed_at, + self.created_by_ids.iter().map(|&id| u64_to_i64(id)).collect::>(), + self.reviewed_by_id.map(u64_to_i64), + self.resolution.as_ref(), + ), + ) + .await + .map_err(DatabaseError::QueryError)?; + Ok(()) + } +} + #[derive(Debug, Clone)] pub struct Server { pub id: u64, diff --git a/src/framework/bot.rs b/src/framework/bot.rs index 769c283..99a24c9 100644 --- a/src/framework/bot.rs +++ b/src/framework/bot.rs @@ -90,12 +90,16 @@ where } } +use crate::framework::interactions::message_command::MessageCommand; +use crate::framework::interactions::message_command_registry::MessageCommandRegistry; + pub struct Bot { state: Arc, prefixes: Arc + Send + Sync>, intents: Intents, commands: Vec>>, components: Vec>>, + message_commands: Vec>>, on_ready_handlers: Vec>>, on_reconnect_handlers: Vec>>, on_interaction_handlers: Vec>>, @@ -115,6 +119,7 @@ impl Bot { intents: Intents::empty(), commands: Vec::new(), components: Vec::new(), + message_commands: Vec::new(), on_ready_handlers: Vec::new(), on_reconnect_handlers: Vec::new(), on_interaction_handlers: Vec::new(), @@ -126,6 +131,10 @@ impl Bot { on_server_delete_handlers: Vec::new(), } } + pub fn message_command(mut self, command: impl MessageCommand + 'static) -> Self { + self.message_commands.push(Arc::new(command)); + self + } pub fn intent(mut self, intent: Intents) -> Self { self.intents.insert(intent); @@ -198,17 +207,21 @@ impl Bot { .await .inspect_err(|e| log::error!("Could not parse application data! {e:?}")) .map_err(|_| RuntimeError::ApplicationFetchingFailed)?; - let registry = ComponentRegistry::new(); + let registry = ComponentRegistry::new(); for component in &self.components { let component_id = component.custom_id(); - registry.register( component_id.expect("The component registered by default must have a custom ID"), component.clone(), ); } + let message_command_registry = std::sync::Arc::new(MessageCommandRegistry::new()); + for command in &self.message_commands { + message_command_registry.register(command.name(), command.clone()); + } + let bot = Arc::new(self); let runtime = Arc::new(Runtime { @@ -227,6 +240,7 @@ impl Bot { let task_rt = runtime.clone(); let task_bot = bot.clone(); + let message_command_registry = message_command_registry.clone(); tokio::spawn(async move { match event { Event::Ready(event) => { @@ -252,14 +266,21 @@ impl Bot { } } Event::InteractionCreate(interaction) => { - for handler in &task_bot.on_interaction_handlers { - handler - .on_interaction(&interaction, task_rt.clone()) - .await - .inspect_err( - |e| log::error!("An error occurred while handling on interaction for {handler:?}! {e:?}") - ) - .bye(); + // First, try message command registry + if let Err(e) = message_command_registry.execute(&interaction, task_rt.clone()).await { + // If not a message command, fall back to interaction handlers + for handler in &task_bot.on_interaction_handlers { + handler + .on_interaction(&interaction, task_rt.clone()) + .await + .inspect_err( + |e| log::error!("An error occurred while handling on interaction for {handler:?}! {e:?}") + ) + .bye(); + } + if !matches!(e, crate::framework::interactions::InteractionError::InteractionTypeUnsent) { + log::error!("Message command dispatch error: {e:?}"); + } } } Event::MessageCreate(message) => { diff --git a/src/framework/interactions/message_command.rs b/src/framework/interactions/message_command.rs new file mode 100644 index 0000000..6a7eb5f --- /dev/null +++ b/src/framework/interactions/message_command.rs @@ -0,0 +1,13 @@ +use std::fmt::Debug; +use async_trait::async_trait; +use twilight_model::gateway::payload::incoming::InteractionCreate; +use crate::framework::interactions::{InteractionError, context::InteractionContext}; + +#[async_trait] +pub trait MessageCommand: Send + Sync + Debug { + fn name(&self) -> &'static str; + fn description(&self) -> &'static str { + "A message context menu command duh." + } + async fn execute(&self, ctx: InteractionContext, target_message_id: u64) -> Result<(), InteractionError>; +} diff --git a/src/framework/interactions/message_command_registry.rs b/src/framework/interactions/message_command_registry.rs new file mode 100644 index 0000000..e6d85cc --- /dev/null +++ b/src/framework/interactions/message_command_registry.rs @@ -0,0 +1,76 @@ +use std::sync::Arc; +use scc::HashMap; +use twilight_model::{ + application::interaction::InteractionData, application::command::CommandType, gateway::payload::incoming::InteractionCreate, +}; +use crate::framework::{ + interactions::{InteractionError, context::InteractionContext, message_command::MessageCommand}, + runtime::Runtime, +}; + +#[derive(Default)] +pub struct MessageCommandRegistry { + inner: HashMap>>, +} + +impl MessageCommandRegistry +where + T: Send + Sync + 'static, +{ + pub fn new() -> Self { + Self { + inner: Default::default(), + } + } + + pub fn register(&self, name: impl ToString, command: Arc>) { + self.inner.upsert(name.to_string(), command); + } + + pub fn unregister(&self, name: impl ToString) { + self.inner.remove(&name.to_string()); + } + + pub async fn execute( + &self, + event: &InteractionCreate, + rt: Arc>, + ) -> Result<(), InteractionError> { + if let Some(data) = &event.data { + if let InteractionData::ApplicationCommand(data) = data { + if data.kind == CommandType::Message { + if let Some(command) = self.inner.get_async(&data.name).await { + let prefixes = rt + .get_prefixes( + event.guild_id.map(|id| id.get()), + event.author_id().map(|id| id.get()), + ) + .await; + let prefix = prefixes + .first() + .ok_or(InteractionError::PrefixesVectorEmpty)?; + // Message commands have a resolved target_id + if let Some(target_id) = data.target_id { + command + .execute( + InteractionContext::new(event.clone(), rt.clone(), prefix.to_string()), + target_id.get(), + ) + .await + } else { + Err(InteractionError::InteractionNotFound) + } + } else { + Err(InteractionError::InteractionNotFound) + } + } else { + Err(InteractionError::InteractionTypeUnsent) + } + } else { + Err(InteractionError::InteractionTypeUnsent) + } + } else { + Err(InteractionError::InteractionTypeUnsent) + } + } +} diff --git a/src/framework/interactions/mod.rs b/src/framework/interactions/mod.rs index ac63d52..28f51f4 100644 --- a/src/framework/interactions/mod.rs +++ b/src/framework/interactions/mod.rs @@ -4,6 +4,8 @@ pub mod components; pub mod context; pub mod registry; pub mod runner; +pub mod message_command; +pub mod message_command_registry; #[derive(Debug, Error)] pub enum InteractionError { diff --git a/src/groups/calls/commands.rs b/src/groups/calls/commands.rs index 44b2390..0654258 100644 --- a/src/groups/calls/commands.rs +++ b/src/groups/calls/commands.rs @@ -3,11 +3,74 @@ use scc::HashSet; use twilight_model::id::Id; use crate::{ - database::models::{Call, Channel}, + database::models::{Call, Channel, Message, Report}, framework::commands::{Command, CommandError, context::CommandContext}, + framework::interactions::{message_command::MessageCommand, InteractionError, context::InteractionContext}, state::State, - utils::Bye, + utils::{Bye, get_random_u64}, }; +#[derive(Debug, Default)] +pub struct ReportMessageCommand; + +#[async_trait] +impl MessageCommand for ReportMessageCommand { + fn name(&self) -> &'static str { + "Report Message" + } + + fn description(&self) -> &'static str { + "Report a message to the moderators." + } + + async fn execute(&self, ctx: InteractionContext, target_message_id: u64) -> Result<(), InteractionError> { + + // Extract channel_id from resolved data or fallback to event.channel (Discord API v10+) + let channel_id = ctx.event.channel.as_ref().map(|c| c.id.get()) + .or_else(|| ctx.event.channel.as_ref().map(|c| c.id.get())) + .ok_or(InteractionError::InteractionNotFound)?; + let db = &ctx.rt.state.db; + let channel = Channel::get_by_id(channel_id, db).await.map_err(|_| InteractionError::InteractionNotFound)?; + let call_id = channel.and_then(|c| c.call_id).ok_or(InteractionError::InteractionNotFound)?; + + // Fetch the message from the database + let message = Message::get_by_id(call_id, target_message_id, db) + .await + .map_err(|_| InteractionError::InteractionNotFound)? + .ok_or(InteractionError::InteractionNotFound)?; + + // Build the report using the new UDT-based message metadata + let report = Report { + report_id: get_random_u64(), + message: message.message_metadata.clone(), + created_at: time::OffsetDateTime::now_utc(), + reviewed_at: None, + created_by_ids: vec![ctx.event.author_id().map(|id| id.get()).unwrap_or(0)], + reviewed_by_id: None, + resolution: None, + }; + + // Insert the report + report.insert(db).await.map_err(|_| InteractionError::DeferringFailed)?; + + // Respond to the user + ctx.rt.http + .interaction(ctx.rt.app.id) + .create_response( + ctx.event.id, + &ctx.event.token, + &twilight_model::http::interaction::InteractionResponse { + kind: twilight_model::http::interaction::InteractionResponseType::ChannelMessageWithSource, + data: Some(twilight_model::http::interaction::InteractionResponseData { + content: Some("Message has been reported to the moderators.".to_string()), + ..Default::default() + }), + }, + ) + .await + .map_err(|_| InteractionError::DeferringFailed)?; + Ok(()) + } +} #[derive(Default, Debug)] pub struct CallCommand { diff --git a/src/main.rs b/src/main.rs index 46395ec..af8f3b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -185,6 +185,7 @@ async fn start(token: String, database_addr: String) { .command(CallCommand::new()) .command(HangupCommand) .component(HelpGoBackButton) + .message_command(crate::groups::calls::commands::ReportMessageCommand) .on_ready(ReadyHandler) .on_reconnect(ReconnectHandler) .on_message(MessageRedirector)