diff --git a/Cargo.lock b/Cargo.lock index 6d08c80..fefa082 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1049,6 +1049,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.27" @@ -2397,6 +2403,7 @@ version = "0.2.3" dependencies = [ "anyhow", "dotenv", + "humantime", "memorable-wordlist", "once_cell", "serde", diff --git a/Cargo.toml b/Cargo.toml index d9cfeed..29cd471 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ authors = ["Raphael Darley "] [dependencies] anyhow = "1.0.71" dotenv = "0.15.0" +humantime = "2.1.0" memorable-wordlist = "0.1.7" once_cell = "1.18.0" serde = "1.0.166" diff --git a/src/commands/clean.rs b/src/commands/clean.rs index 4e101f1..5124cae 100644 --- a/src/commands/clean.rs +++ b/src/commands/clean.rs @@ -31,12 +31,21 @@ pub async fn run( let channel = match command.channel_id.to_channel(&ctx).await.unwrap() { Channel::Guild(c) => c, _ => { - interaction_reply_ephemeral(command, ctx, ":warning: This command only works in guild channels") - .await?; + interaction_reply_ephemeral( + command, + ctx, + ":warning: This command only works in guild channels", + ) + .await?; return Ok(()); } }; - interaction_reply_ephemeral(command, ctx.clone(), ":white_check_mark: This channel should now be cleaned").await?; + interaction_reply_ephemeral( + command, + ctx.clone(), + ":white_check_mark: This channel should now be cleaned", + ) + .await?; clean_channel(channel, &ctx).await; diff --git a/src/commands/clean_all.rs b/src/commands/clean_all.rs index b80145e..0363afc 100644 --- a/src/commands/clean_all.rs +++ b/src/commands/clean_all.rs @@ -17,19 +17,30 @@ pub async fn run( ) -> Result<(), anyhow::Error> { let map = (*DBCONNS.lock().await).clone(); for id in map.keys() { - let channel = match ChannelId(id.clone()).to_channel(&ctx).await.unwrap() { + let channel = match ChannelId(*id).to_channel(&ctx).await.unwrap() { Channel::Guild(c) => c, _ => { - interaction_reply_ephemeral(command, ctx, ":warning: This command only works in guild channels") - .await?; + interaction_reply_ephemeral( + command, + ctx, + ":warning: This command only works in guild channels", + ) + .await?; return Ok(()); } }; let (channel, ctx) = (channel.clone(), ctx.clone()); - tokio::spawn(async move { clean_channel(channel, &ctx).await }.instrument(tracing::Span::current())); + tokio::spawn( + async move { clean_channel(channel, &ctx).await }.instrument(tracing::Span::current()), + ); } - interaction_reply_ephemeral(command, ctx, ":white_check_mark: All channels should now be cleaned").await + interaction_reply_ephemeral( + command, + ctx, + ":white_check_mark: All channels should now be cleaned", + ) + .await } pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { diff --git a/src/commands/config_update.rs b/src/commands/config_update.rs index b63a898..09bc1ee 100644 --- a/src/commands/config_update.rs +++ b/src/commands/config_update.rs @@ -24,7 +24,7 @@ pub async fn run( Ok(response) => match response { Some(c) => {c} - None => return interaction_reply(command, ctx.clone(), format!(":warning: This server is not yet configured, use `/configure` to add initial configuration")).await, + None => return interaction_reply(command, ctx.clone(), ":warning: This server is not yet configured, use `/configure` to add initial configuration".to_string()).await, }, Err(e) => return interaction_reply(command, ctx.clone(), format!("Database error: {}", e)).await, }; @@ -44,18 +44,21 @@ pub async fn run( let msg = match updated { Ok(response) => match response { Some(c) => { - format!(":white_check_mark: This server is now configured with: {:?}", c) + format!( + ":white_check_mark: This server is now configured with: {:?}", + c + ) } None => { warn!("error updating configuration"); ":x: Error updating configuration".to_string() - }, + } }, Err(e) => { error!(error = %e, "database error"); format!(":x: Database error: {}", e) - }, + } }; interaction_reply(command, ctx.clone(), msg).await } diff --git a/src/commands/configure.rs b/src/commands/configure.rs index 151618c..229763c 100644 --- a/src/commands/configure.rs +++ b/src/commands/configure.rs @@ -52,18 +52,21 @@ pub async fn run( let msg = match created { Ok(response) => match response { Some(c) => { - format!(":information_source: This server is now configured with: {:?}", c) + format!( + ":information_source: This server is now configured with: {:?}", + c + ) } None => { warn!("Error adding configuration"); ":x: Error adding configuration".to_string() - }, + } }, Err(e) => { error!(error = %e, "database error"); format!(":x: Database error: {}", e) - }, + } }; interaction_reply(command, ctx.clone(), msg).await } diff --git a/src/commands/connect.rs b/src/commands/connect.rs index 805e686..f366da4 100644 --- a/src/commands/connect.rs +++ b/src/commands/connect.rs @@ -117,15 +117,15 @@ pub async fn run( } Ordering::Less => interaction_reply(command, ctx, format!(":information_source: This channel is now connected to a SurrealDB instance, try writing some SurrealQL with the `/query` command! \n_Please note this channel will expire after {:#?} of inactivity._", config.ttl)).await?, }; - return Ok(()); + Ok(()) } None => { - return interaction_reply( + interaction_reply( command, ctx, ":warning: Direct messages are not currently supported".to_string(), ) - .await; + .await } } } @@ -182,7 +182,7 @@ async fn load_premade( channel .send_files( ctx, - [AttachmentType::Path(&Path::new(&format!( + [AttachmentType::Path(Path::new(&format!( "premade/{}", scheme_file_name )))], diff --git a/src/commands/create.rs b/src/commands/create.rs index 56f1eae..3b7e446 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -55,7 +55,7 @@ pub async fn run( .union(Permissions::READ_MESSAGE_HISTORY), deny: Permissions::empty(), kind: serenity::model::prelude::PermissionOverwriteType::Member(UserId( - command.application_id.as_u64().clone(), + *command.application_id.as_u64(), )), }, PermissionOverwrite { @@ -102,7 +102,7 @@ pub async fn run( channel .send_files( ctx, - [AttachmentType::Path(&Path::new( + [AttachmentType::Path(Path::new( "premade/surreal_deal.png", ))], |m| m.content("schema:"), @@ -123,7 +123,7 @@ pub async fn run( channel .send_files( ctx, - [AttachmentType::Path(&Path::new( + [AttachmentType::Path(Path::new( "premade/surreal_deal.png", ))], |m| m.content("schema:"), @@ -200,8 +200,12 @@ pub async fn run( } } _ => { - interaction_reply_ephemeral(command, ctx, ":x: Unsupported option type") - .await?; + interaction_reply_ephemeral( + command, + ctx, + ":x: Unsupported option type", + ) + .await?; return Ok(()); } } @@ -221,15 +225,15 @@ pub async fn run( false, ) .await?; - return Ok(()); + Ok(()) } None => { - return interaction_reply( + interaction_reply( command, ctx, ":warning: Direct messages are not currently supported".to_string(), ) - .await; + .await } } } diff --git a/src/commands/create_db_thread.rs b/src/commands/create_db_thread.rs index d9ac964..2b2dd20 100644 --- a/src/commands/create_db_thread.rs +++ b/src/commands/create_db_thread.rs @@ -4,6 +4,7 @@ use serenity::model::prelude::application_command::ApplicationCommandInteraction use memorable_wordlist::kebab_case; use serenity::prelude::Context; +use crate::components::show_configurable_session; use crate::utils::*; use crate::config::Config; @@ -38,19 +39,20 @@ pub async fn run( let db = create_db_instance(&config).await?; - channel.say(&ctx, format!(":information_source: This public thread is now connected to a SurrealDB instance. Try writing some SurrealQL! \nIf you want, you can use `/load` to load a premade dataset or your own SurrealQL from a file. \n_Please note this channel will expire after {:#?} of inactivity._", config.ttl)).await?; + // channel.say(&ctx, format!(":information_source: This public thread is now connected to a SurrealDB instance. Try writing some SurrealQL! \nIf you want, you can use `/load` to load a premade dataset or your own SurrealQL from a file. \n_Please note this channel will expire after {:#?} of inactivity._", config.ttl)).await?; + show_configurable_session(&ctx, &channel, crate::ConnType::Thread, config.ttl).await?; interaction_reply_ephemeral(command, ctx.clone(), format!(":information_source: You now have your own database instance! Head over to <#{}> to start writing SurrealQL!", channel.id.as_u64())).await?; register_db(ctx, db, channel, config, crate::ConnType::Thread, true).await?; - return Ok(()); + Ok(()) } None => { - return interaction_reply( + interaction_reply( command, ctx, ":warning: Direct messages are not currently supported".to_string(), ) - .await; + .await } } } diff --git a/src/commands/export.rs b/src/commands/export.rs index 24a8fa6..216a450 100644 --- a/src/commands/export.rs +++ b/src/commands/export.rs @@ -29,7 +29,12 @@ pub async fn run( return Ok(()); } }; - interaction_reply(command, ctx.clone(), ":information_source: Exporting database").await?; + interaction_reply( + command, + ctx.clone(), + ":information_source: Exporting database", + ) + .await?; let base_path = match env::var("TEMP_DIR_PATH") { Ok(p) => p, @@ -71,7 +76,8 @@ pub async fn run( fs::remove_file(path).await?; } Err(why) => { - interaction_reply_edit(command, ctx, format!(":x: Database export failed: {why}")).await? + interaction_reply_edit(command, ctx, format!(":x: Database export failed: {why}")) + .await? } }; Ok(()) diff --git a/src/commands/load.rs b/src/commands/load.rs index 8003ed0..62fcef0 100644 --- a/src/commands/load.rs +++ b/src/commands/load.rs @@ -23,7 +23,7 @@ pub async fn run( command: &ApplicationCommandInteraction, ctx: Context, ) -> Result<(), anyhow::Error> { - if command.data.options.len() == 0 { + if command.data.options.is_empty() { interaction_reply_ephemeral( command, ctx, @@ -97,8 +97,12 @@ pub async fn run( load_attachment(op_option, command, ctx, db, channel).await? } _ => { - interaction_reply_ephemeral(command, ctx, ":x: Unsupported option type") - .await?; + interaction_reply_ephemeral( + command, + ctx, + ":x: Unsupported option type", + ) + .await?; return Ok(()); } } @@ -106,15 +110,15 @@ pub async fn run( Ordering::Less => panic!(), }; - return Ok(()); + Ok(()) } None => { - return interaction_reply( + interaction_reply( command, ctx, ":warning: Direct messages are not currently supported".to_string(), ) - .await; + .await } } } @@ -171,7 +175,7 @@ async fn load_premade( channel .send_files( ctx, - [AttachmentType::Path(&Path::new(&format!( + [AttachmentType::Path(Path::new(&format!( "premade/{}", scheme_file_name )))], diff --git a/src/components/configurable_session.rs b/src/components/configurable_session.rs new file mode 100644 index 0000000..d87b432 --- /dev/null +++ b/src/components/configurable_session.rs @@ -0,0 +1,64 @@ +use std::time::Duration; + +use serenity::{model::prelude::{GuildChannel, component::ButtonStyle::{Primary, Danger}, ChannelId}, prelude::Context}; + +use crate::ConnType; +use anyhow::Result; +use humantime::format_duration; + +/// Send a message to the server with prebuilt components for DB channel configuration management +pub async fn show_configurable_session( + ctx: &Context, + channel: &GuildChannel, + conn: ConnType, + ttl: Duration, +) -> Result<()> { + match conn { + ConnType::ConnectedChannel => todo!(), + ConnType::EphemeralChannel => todo!(), + ConnType::Thread => channel.send_message(&ctx, |message| { + message + .embed(|embed| { + embed + .title("Your SurrealDB session") + .description("This public thread is now connected to a SurrealDB instance. \nTry writing some SurrealQL! \n\nIf you want, you can use `/load` to load a premade dataset or your own SurrealQL from a file.") + .field("Session Lifetime after last query", format_duration(ttl), false) + }) + .components(|c| { + c.create_action_row(|r| { + r.create_select_menu(|s| { + s.custom_id("configurable_session:format").placeholder("Select output format").min_values(1).max_values(1).options(|o| { + o.create_option(|o| o.default_selection(true).label("JSON format").value("json")) + .create_option(|o| o.label("SQL-like format").value("sql")) + }) + }) + }).create_action_row(|r|{ + r.create_select_menu(|s| { + s.custom_id("configurable_session:prettify").placeholder("Prettify output").min_values(1).max_values(1).options(|o| { + o.create_option(|o| o.default_selection(true).label("Pretty output").value("true")) + .create_option(|o| o.label("Raw output").value("false")) + }) + }) + }).create_action_row(|r|{ + r.create_select_menu(|s| { + s.custom_id("configurable_session:require_query").placeholder("Require /query").min_values(1).max_values(1).options(|o| { + o.create_option(|o| o.default_selection(true).label("Must use /query or /q").value("true")) + .create_option(|o| o.label("All messages are queries").value("false")) + }) + }) + }).create_action_row(|r| { + r.create_button(|b| b.custom_id("configurable_session:export").label("Export").style(Primary)) + .create_button(|b| b.custom_id("configurable_session:stop").label("Stop and cleanup").style(Danger)) + }) + }) + }), + } + .await?; + + Ok(()) +} + +#[instrument(skip(ctx))] +pub async fn handle_session_component(ctx: &Context, channel: &ChannelId, id: &str) -> Result<()> { + Ok(()) +} diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..097689c --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,3 @@ +mod configurable_session; + +pub use configurable_session::{show_configurable_session, handle_session_component}; diff --git a/src/handler.rs b/src/handler.rs index 1cbfca1..ed2e9aa 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -16,7 +16,7 @@ use crate::utils::respond; use crate::DBCONNS; fn validate_msg(msg: &Message) -> bool { - if msg.author.bot == true { + if msg.author.bot { return false; }; true @@ -74,57 +74,94 @@ impl EventHandler for Handler { } async fn interaction_create(&self, ctx: Context, interaction: Interaction) { - if let Interaction::ApplicationCommand(command) = interaction { - let span = span!( - Level::DEBUG, - "application_command", - interaction_id = command.id.0, - guild_id = %command.guild_id.unwrap_or_default(), - channel_id = %command.channel_id, - user = %command.user, - command_name = %command.data.name - ); - - async { - trace!(command = ?command, "received command interaction"); - let res = match command.data.name.as_str() { - "create" => commands::create::run(&command, ctx.clone()).await, - "configure" => commands::configure::run(&command, ctx.clone()).await, - "share" => commands::share::run(&command, ctx.clone()).await, - "create_db_thread" => commands::create_db_thread::run(&command, ctx.clone()).await, - "load" => commands::load::run(&command, ctx.clone()).await, - "config_update" => commands::config_update::run(&command, ctx.clone()).await, - "clean_all" => commands::clean_all::run(&command, ctx.clone()).await, - "clean" => commands::clean::run(&command, ctx.clone()).await, - "configure_channel" => { - commands::configure_channel::run(&command, ctx.clone()).await + match interaction { + Interaction::ApplicationCommand(command) => { + let span = span!( + Level::DEBUG, + "application_command", + interaction_id = command.id.0, + guild_id = %command.guild_id.unwrap_or_default(), + channel_id = %command.channel_id, + user = %command.user, + command_name = %command.data.name + ); + + async { + trace!(command = ?command, "received command interaction"); + let res = match command.data.name.as_str() { + "create" => commands::create::run(&command, ctx.clone()).await, + "configure" => commands::configure::run(&command, ctx.clone()).await, + "share" => commands::share::run(&command, ctx.clone()).await, + "create_db_thread" => commands::create_db_thread::run(&command, ctx.clone()).await, + "load" => commands::load::run(&command, ctx.clone()).await, + "config_update" => commands::config_update::run(&command, ctx.clone()).await, + "clean_all" => commands::clean_all::run(&command, ctx.clone()).await, + "clean" => commands::clean::run(&command, ctx.clone()).await, + "configure_channel" => { + commands::configure_channel::run(&command, ctx.clone()).await + } + "query" => commands::query::run(&command, ctx.clone()).await, + "q" => commands::q::run(&command, ctx.clone()).await, + "connect" => commands::connect::run(&command, ctx.clone()).await, + "export" => commands::export::run(&command, ctx.clone()).await, + _ => { + warn!(command_name = %command.data.name, command_options = ?command.data.options, "unknown command received"); + interaction_reply( + &command, + ctx.clone(), + ":warning: Command is currently not implemented".to_string(), + ) + .await + } + }; + + if let Err(why) = res { + command + .delete_original_interaction_response(&ctx) + .await + .ok(); + warn!(error = %why, "Cannot respond to slash command"); + interaction_reply_ephemeral(&command, ctx, ":x: Error processing command".to_string()) + .await + .unwrap(); } - "query" => commands::query::run(&command, ctx.clone()).await, - "q" => commands::q::run(&command, ctx.clone()).await, - "connect" => commands::connect::run(&command, ctx.clone()).await, - "export" => commands::export::run(&command, ctx.clone()).await, - _ => { - warn!(command_name = %command.data.name, command_options = ?command.data.options, "unknown command received"); - interaction_reply( - &command, - ctx.clone(), - ":warning: Command is currently not implemented".to_string(), - ) - .await + }.instrument(span).await; + } + Interaction::MessageComponent(event) => { + let span = span!( + Level::DEBUG, + "message_component", + interaction_id = event.id.0, + guild_id = %event.guild_id.unwrap_or_default(), + channel_id = %event.channel_id, + user = %event.user, + component_id = %event.data.custom_id + ); + async move { + trace!(event = ?event, "received component interaction"); + let res = match event.data.custom_id.split_once(':') { + Some(("configurable_session", id)) => { + crate::components::handle_session_component( + &ctx, + &event.channel_id, + &id, + ) + .await + } + _ => Ok(()), + }; + + if let Err(why) = res { + event.delete_original_interaction_response(&ctx).await.ok(); + warn!(error = %why, "Failed to process component interaction"); } - }; - - if let Err(why) = res { - command - .delete_original_interaction_response(&ctx) - .await - .ok(); - warn!(error = %why, "Cannot respond to slash command"); - interaction_reply_ephemeral(&command, ctx, format!(":x: Error processing commang")) - .await - .unwrap(); } - }.instrument(span).await; + .instrument(span) + .await; + } + _ => { + warn!("unknown interaction received"); + } } } diff --git a/src/lib.rs b/src/lib.rs index ae823dd..64ec4a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod channel_info; pub mod commands; +pub mod components; pub mod config; pub mod db_utils; pub mod handler; diff --git a/src/utils.rs b/src/utils.rs index 2ae5cd2..9de03d3 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -85,7 +85,7 @@ pub fn read_view_perms(kind: PermissionOverwriteType) -> PermissionOverwrite { .union(Permissions::SEND_MESSAGES) .union(Permissions::READ_MESSAGE_HISTORY), deny: Permissions::empty(), - kind: kind, + kind, } } @@ -178,39 +178,42 @@ pub async fn register_db( ) -> Result<(), anyhow::Error> { info!("Registering a new database"); DBCONNS.lock().await.insert( - channel.id.as_u64().clone(), + *channel.id.as_u64(), crate::Conn { db, last_used: Instant::now(), conn_type, - ttl: config.ttl.clone(), - pretty: config.pretty.clone(), - json: config.json.clone(), + ttl: config.ttl, + pretty: config.pretty, + json: config.json, require_query, }, ); - tokio::spawn(async move { - let mut last_time; - let mut ttl; - loop { - match DBCONNS.lock().await.get(channel.id.as_u64()) { - Some(e) => { - last_time = e.last_used; - ttl = e.ttl + tokio::spawn( + async move { + let mut last_time; + let mut ttl; + loop { + match DBCONNS.lock().await.get(channel.id.as_u64()) { + Some(e) => { + last_time = e.last_used; + ttl = e.ttl + } + None => { + clean_channel(channel, &ctx).await; + break; + } } - None => { + if last_time.elapsed() >= ttl { clean_channel(channel, &ctx).await; break; } + sleep_until(last_time + ttl).await; } - if last_time.elapsed() >= ttl { - clean_channel(channel, &ctx).await; - break; - } - sleep_until(last_time + ttl).await; } - }.instrument(tracing::Span::current())); + .instrument(tracing::Span::current()), + ); Ok(()) } @@ -250,7 +253,9 @@ pub async fn respond( .send_message(&ctx, |m| { let message = m.reference_message(&query_msg).add_file(reply_attachment); if truncated { - message.content(":information_source: Response was too long and has been truncated") + message.content( + ":information_source: Response was too long and has been truncated", + ) } else { message } @@ -280,7 +285,7 @@ pub async fn load_attachment( .await?; match attachment.download().await { Ok(data) => { - interaction_reply_edit(command, ctx.clone(), format!(":information_source: Your data is currently being loaded, soon you'll be able to query your dataset! \n_Please wait for a confirmation that the dataset is loaded!_")).await?; + interaction_reply_edit(command, ctx.clone(), ":information_source: Your data is currently being loaded, soon you'll be able to query your dataset! \n_Please wait for a confirmation that the dataset is loaded!_".to_string()).await?; let db = db.clone(); let (_channel, ctx, command) = (channel.clone(), ctx.clone(), command.clone()); @@ -298,7 +303,7 @@ pub async fn load_attachment( interaction_reply_edit( &command, ctx, - format!(":information_source: Your data is now loaded and ready to query!"), + ":information_source: Your data is now loaded and ready to query!".to_string(), ) .await .unwrap();