diff --git a/.gitignore b/.gitignore index 956e389..fb6e911 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .idea/ Cargo.lock +*.db diff --git a/Cargo.toml b/Cargo.toml index c1f37f4..2f24248 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,30 @@ [package] name = "manifold" -version = "0.1.0" -authors = ["Lucy Bladen "] +version = "1.0.0" +authors = ["Lucy Bladen "] edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [build-dependencies] -built = { version = "0.5.1", features = ["git2", "chrono"] } +built = { version = "0.6.0", features = ["git2", "chrono"] } [dependencies] -built = { version = "0.5.1", features = ["git2", "chrono"] } -clap = "3.2.10" +built = { version = "0.6.0", features = ["git2", "chrono"] } +chrono = "0.4.26" +clap = "4.3.4" config = { version = "0.13.1", features = [ "yaml" ] } -diesel = { version = "1.4.8", features = ["sqlite", "r2d2", "chrono"] } -diesel_migrations = "1.4.0" -env_logger = "0.9.0" +d20 = "0.1.0" +diesel = { version = "2.1.0", features = ["sqlite", "r2d2", "chrono"] } +diesel_migrations = "2.1.0" +env_logger = "0.10.0" log = "0.4.14" +num = "0.4.1" +poise = "0.5.5" r2d2 = "0.8.9" rand = "0.8.5" regex = "1.5.4" reqwest = "0.11.9" serde = "1.0.136" serde_json = "1.0.79" -serenity = { version = "0.11", features = [ "collector", "unstable_discord_api" ] } tokio = { version = "1.16.1", features = ["sync", "macros", "rt-multi-thread"] } diff --git a/diesel.toml b/diesel.toml new file mode 100644 index 0000000..c028f4a --- /dev/null +++ b/diesel.toml @@ -0,0 +1,9 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId"] + +[migrations_directory] +dir = "migrations" diff --git a/migrations/2023-08-22-112222_create database tables/down.sql b/migrations/2023-08-22-112222_create database tables/down.sql new file mode 100644 index 0000000..196e5da --- /dev/null +++ b/migrations/2023-08-22-112222_create database tables/down.sql @@ -0,0 +1 @@ +DROP TABLE "userinfo"; \ No newline at end of file diff --git a/migrations/2023-08-22-112222_create database tables/up.sql b/migrations/2023-08-22-112222_create database tables/up.sql new file mode 100644 index 0000000..a318866 --- /dev/null +++ b/migrations/2023-08-22-112222_create database tables/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS "userinfo" +( + user_id BIGINT PRIMARY KEY NOT NULL, + username TEXT NOT NULL, + weather_location VARCHAR(36), + weather_units VARCHAR(1), + timezone VARCHAR(8), + last_seen BIGINT +); diff --git a/src/commands/core.rs b/src/commands/core.rs new file mode 100644 index 0000000..e5a46cb --- /dev/null +++ b/src/commands/core.rs @@ -0,0 +1,98 @@ +use poise::serenity_prelude::*; + +use crate::{ManifoldContext, ManifoldData}; +use crate::built_info; +use crate::error::{ManifoldError, ManifoldResult}; + +#[poise::command(prefix_command, track_edits, slash_command)] +async fn help( + ctx: ManifoldContext<'_>, + #[description = "Help about a specific command"] command: Option +) -> ManifoldResult<()> { + + let responses = &ctx.data().responses; + + let config = poise::builtins::HelpConfiguration { + extra_text_at_bottom: &*format!("{}", responses.get_response(&"help footer".to_string()).unwrap_or(&"".to_string())), + ..Default::default() + }; + + poise::builtins::help(ctx, command.as_deref(), config).await?; + + Ok(()) +} + +#[poise::command(slash_command, prefix_command)] +async fn ping(ctx: ManifoldContext<'_>,) -> ManifoldResult<()> { + let requestor = { + let calling_user = ctx.author(); + calling_user.mention().to_string() + }; + + ctx.say(format!("{}: Ping? Pong!", requestor)).await?; + + Ok(()) +} + +#[poise::command(slash_command, prefix_command, owners_only)] +async fn register_commands(ctx: ManifoldContext<'_>) -> ManifoldResult<()> { + poise::builtins::register_application_commands_buttons(ctx).await?; + + Ok(()) +} + +#[poise::command(slash_command, prefix_command, track_edits, aliases("sa"), required_permissions = "MODERATE_MEMBERS")] +async fn set_activity(ctx: ManifoldContext<'_>, #[rest] #[description="Who to watch"] target: String) -> ManifoldResult<()> { + ctx.serenity_context().set_activity(Activity::watching(&target)).await; + + ctx.say(format!("Okay, I'll start watching {}", target)).await?; + Ok(()) +} + +#[poise::command(slash_command, prefix_command)] +async fn version(ctx: ManifoldContext<'_>) -> ManifoldResult<()> { + let git_info: String = built_info::GIT_VERSION.unwrap_or("unknown").to_string(); + let version_string: String = format!("Version {} built at {} revision {}", built_info::PKG_VERSION, built_info::BUILT_TIME_UTC, git_info); + + ctx.send(|f| f + .reply(true) + .content(version_string)).await?; + + Ok(()) +} + +#[poise::command(slash_command, prefix_command, owners_only)] +async fn get_config(ctx: ManifoldContext<'_>, #[description="Config key"] key: String) -> ManifoldResult<()> { + let config = &ctx.data().bot_config; + + let value = match config.get_value(&key) { + Ok(v) => v.clone(), + Err(_) => "not found, sorry!".to_string() + }; + + ctx.say(format!("Value for key {} was {}", &key, &value)).await?; + + Ok(()) +} + +#[poise::command(slash_command, prefix_command, owners_only)] +async fn dump_config(ctx: ManifoldContext<'_>) -> ManifoldResult<()> { + let config = &ctx.data().bot_config; + + ctx.say(format!("Config dump; {:?}", config.config)).await?; + + Ok(()) +} + +#[poise::command(slash_command, prefix_command, owners_only)] +async fn get_environment(ctx: ManifoldContext<'_>) -> ManifoldResult<()> { + let environment = ctx.data().bot_config.get_environment(); + + ctx.say(format!("Currently running under the {} environment", environment)).await?; + + Ok(()) +} + +pub fn commands() -> [poise::Command; 8] { + [help(), ping(), register_commands(), set_activity(), version(), get_config(), dump_config(), get_environment()] +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..9485790 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,11 @@ +mod core; +mod weather; + +use crate::ManifoldCommand; + +pub fn collect_commands(injected: Vec) -> Vec { + core::commands().into_iter() + .chain(weather::commands()) + .chain(injected) + .collect() +} diff --git a/src/commands/weather.rs b/src/commands/weather.rs new file mode 100644 index 0000000..91b9371 --- /dev/null +++ b/src/commands/weather.rs @@ -0,0 +1,174 @@ +use poise::serenity_prelude::utils::Colour; +use d20::roll_range; +use poise::ReplyHandle; +use crate::{ManifoldCommand, ManifoldContext}; + +use crate::models::weather::{Weather, WeatherForecastRequestResponse}; +use crate::models::user::UserInfo; +use crate::error::ManifoldResult; + +#[poise::command(slash_command, prefix_command, aliases("w"))] +async fn weather(ctx: ManifoldContext<'_>, #[rest] #[description="Location to look up weather for"] location: Option) -> ManifoldResult<()> { + let my_message = ctx.say("Retrieving weather, be patient").await?; + + let weather_forecast = _get_weather(ctx, &my_message, location, Some(1)).await?; + + let responses = &ctx.data().responses; + + my_message.edit(ctx, |m| { + m.content(""); + m.embed(|e| { + + let card_colour = Colour::from_rgb(roll_range(0, 255).unwrap_or(0) as u8, roll_range(0, 255).unwrap_or(0) as u8, roll_range(0, 255).unwrap_or(0) as u8); + + e.colour(card_colour); + e.title(format!("Current weather at {}, {}, {}", weather_forecast.location.name, weather_forecast.location.region, weather_forecast.location.country)); + e.description(format!("Observations recorded at {}.", weather_forecast.current.last_updated)); + e.image(format!("https:{}", weather_forecast.current.condition.icon)); + e.fields(vec![ + ("Temperature (Dewpoint)", format!("{}°C/{}°F ({:.1}°C/{:.1}°F)", weather_forecast.current.temp_c, weather_forecast.current.temp_f, weather_forecast.current.dewpoint_c.unwrap_or(0.0), weather_forecast.current.dewpoint_f.unwrap_or(0.0)), true), + ("Feels like", format!("{}°C/{}°F", weather_forecast.current.feelslike_c, weather_forecast.current.feelslike_f), true), + ("Condition", format!("{}", weather_forecast.current.condition.text), true), + ("Pressure", format!("{}mb/{}in", weather_forecast.current.pressure_mb, weather_forecast.current.pressure_in), true), + ("Precipitation", format!("{}mm/{}in", weather_forecast.current.precip_mm, weather_forecast.current.precip_in), true), + ("Humidity", format!("{}%", weather_forecast.current.humidity), true), + ("Cloud coverage", format!("{}%", weather_forecast.current.cloud), true), + ("UV index", format!("{}", weather_forecast.current.uv), true), + ("Coordinates", format!("Lat: {} Lon: {}", weather_forecast.location.lat, weather_forecast.location.lon), true), + ]); + e.field("Wind", format!("{}mph/{}kph from the {} ({} degrees), gusting to {}mph/{}kph", weather_forecast.current.wind_mph, weather_forecast.current.wind_kph, weather_forecast.current.wind_dir, weather_forecast.current.wind_degree, weather_forecast.current.gust_mph, weather_forecast.current.gust_kph), false); + e.footer(|f| { + f.text(format!("{}", responses.get_response(&"weather card footer".to_string()).unwrap_or(&"Weather Powered By Deez Nutz".to_string()))); + f + }); + e + }); + m + }).await?; + + Ok(()) +} + +/* +#[command] +#[aliases("wf")] +pub async fn weather_forecast(ctx: &Context, msg: &Message, args: Args) -> CommandResult { + + let mut my_message = msg.reply_ping(&ctx, "Retrieving weather, be patient").await?; + + let weather_forecast = _get_weather(ctx, msg, args, Some(3)).await?; + + let forecast_index: usize = (chrono::NaiveDateTime::parse_from_str(&*weather_forecast.location.localtime, "%Y-%m-%d %H:%M").unwrap().hour() as usize) + 1; + + debug!("{:?}", weather_forecast); + + my_message.edit(&ctx, |m| { + m.content(""); + + for day in &weather_forecast.forecast.forecastday { + m.add_embed(|e| { + + let this_forecast = &day.hour[forecast_index]; + let card_colour = Colour::from_rgb(roll_range(0, 255).unwrap_or(0) as u8, roll_range(0, 255).unwrap_or(0) as u8, roll_range(0, 255).unwrap_or(0) as u8); + + e.colour(card_colour); + e.title(format!("Weather for {} at {}, {}, {}", this_forecast.time, weather_forecast.location.name, weather_forecast.location.region, weather_forecast.location.country)); + e.description(format!("Observations recorded at {}.", weather_forecast.current.last_updated)); + e.fields(vec![ + ("Temperature (Dewpoint)", format!("{}°C/{}°F ({:.1}°C/{:.1}°F)", this_forecast.temp_c, this_forecast.temp_f, this_forecast.dewpoint_c, this_forecast.dewpoint_f), true), + ("Feels like", format!("{}°C/{}°F", this_forecast.feelslike_c, this_forecast.feelslike_f), true), + ("Condition", format!("{}", this_forecast.condition.text), true), + ("Pressure", format!("{}mb/{}in", this_forecast.pressure_mb, this_forecast.pressure_in), true), + ("Precipitation", format!("{}mm/{}in", this_forecast.precip_mm, this_forecast.precip_in), true), + ("Humidity", format!("{}%", this_forecast.humidity), true), + ]); + e.field("Wind", format!("{}mph/{}kph from the {} ({} degrees), gusting to {}mph/{}kph", this_forecast.wind_mph, this_forecast.wind_kph, this_forecast.wind_dir, this_forecast.wind_degree, this_forecast.gust_mph, this_forecast.gust_kph), false); + e + }); + } + + m + }).await?; + + Ok(()) +} + */ + +#[poise::command(slash_command, prefix_command, aliases("wl"))] +pub async fn save_weather_location(ctx: ManifoldContext<'_>, #[description="Your default weather location"] location: String) -> ManifoldResult<()> { + + let userinfo = &mut ctx.data().user_info.lock().await; + let db = &ctx.data().database; + + if let Some(existing_user) = userinfo.get_mut(&ctx.author().id.as_u64()) { + existing_user.weather_location = Some(location.clone()); + existing_user.save(db)?; + } else { + let new_user = UserInfo { + user_id: ctx.author().id.as_u64().clone() as i64, + username: ctx.author().name.to_owned(), + weather_location: Some(location.clone()), + weather_units: None, + timezone: None, + last_seen: Some(chrono::Utc::now().timestamp()), + }; + new_user.insert(db)?; + userinfo.insert(ctx.author().id.as_u64().clone(), new_user); + } + + ctx.say(format!("Okay. Now I know that you live in {}. Are you sure that was wise?", &location)).await?; + + Ok(()) +} + +pub async fn _get_weather(ctx: ManifoldContext<'_>, message_handle: &ReplyHandle<'_>, location: Option, days: Option) -> ManifoldResult { + + let mut weather_location: String = String::default(); + + let responses = &ctx.data().responses; + let config = &ctx.data().bot_config; + + let userinfo = &mut ctx.data().user_info.lock().await; + + let weather_client = Weather::new(config.get_value(&"WeatherApiKey".to_string()).unwrap().clone(), config.get_value(&"WeatherBaseUrl".to_string()).unwrap().clone()); + + if let Some(loc) = location { + weather_location = loc; + } else { + debug!("No arguments provided, looking for stored user"); + + if let Some(user) = userinfo.get(&ctx.author().id.as_u64()) { + debug!("Have a user reference, checking if they have stored weather info"); + match &user.weather_location { + Some(w) => { + weather_location = w.to_owned(); + }, + None => { + message_handle.edit(ctx, |m| { + m.content = responses.get_response(&"weather noloc".to_string()).cloned(); m + }).await?; + Err("No location provided")?; + } + }; + } else { + ctx.say(responses.get_response(&"weather noloc".to_string()).unwrap_or(&"B I don't know where you live, and you didn't tell me, so I can't help. Look out of the window.".to_string())).await?; + Err("No location provided")?; + } + } + + debug!("Making weather request!"); + + let weather_forecast: WeatherForecastRequestResponse = match weather_client.get_weather_forecast(weather_location, days).await { + Ok(w) => w, + Err(e) => { + ctx.say(format!("Something went wrong. Maybe there is no weather there. {:?}", e)).await?; + Err("unknown")? + } + }; + + Ok(weather_forecast) +} + +pub fn commands() -> [ManifoldCommand; 2] { + [weather(), save_weather_location()] +} diff --git a/src/config.rs b/src/config.rs index c564bd5..38ceb2c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,9 @@ use std::path::PathBuf; use config::Config; -use serenity::model::prelude::{ChannelId, MessageId, RoleId}; +use poise::serenity_prelude::model::prelude::{ChannelId, MessageId, RoleId}; use crate::ManifoldResult; +#[derive(Debug)] pub struct ManifoldConfig { pub environment: String, pub path: PathBuf, diff --git a/src/core_commands.rs b/src/core_commands.rs deleted file mode 100644 index 5dfa4e1..0000000 --- a/src/core_commands.rs +++ /dev/null @@ -1,121 +0,0 @@ -use serenity::{ - prelude::*, - framework::standard::{ - macros::command, - CommandResult, - Args, - }, - model::prelude::* -}; - -use crate::ManifoldConfig; -use crate::built_info; - -#[command] -async fn ping(ctx: &Context, msg: &Message) -> CommandResult { - msg.reply_ping(ctx, "Pong!").await?; - - Ok(()) -} - -#[command] -#[aliases("sa")] -async fn set_activity(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - - let activity_name = args.raw().collect::>().join(" "); - - msg.reply_ping(&ctx, "OK: I'm going to start playing that.").await.unwrap(); - - ctx.set_activity(Activity::playing(&activity_name)).await; - - Ok(()) -} - -#[command] -#[aliases("v")] -async fn version(ctx: &Context, msg: &Message) -> CommandResult { - - let git_info: String = built_info::GIT_VERSION.unwrap_or("unknown").to_string(); - - let version_string: String = format!("Version {} built at {} revision {}", built_info::PKG_VERSION, built_info::BUILT_TIME_UTC, git_info); - - msg.reply_ping(&ctx, &version_string).await.unwrap(); - - Ok(()) -} - -#[command] -async fn get_config(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - let data = &ctx.data.read().await; - let config = match data.get::() { - Some(c) => c.lock().await, - None => Err("Could not lock database!".to_string())? - }; - - let key = match args.single::() { - Ok(k) => k, - Err(e) => { msg.reply_ping(&ctx, format!("Failed to process your input: {:?}", e.to_string())).await?; Err("ArgParse")? } - }; - - let value = match config.get_value(&key) { - Ok(v) => v.clone(), - Err(_) => "not found, sorry!".to_string() - }; - - msg.reply_ping(&ctx, format!("Value for key {} was {}", &key, &value)).await?; - - Ok(()) -} - -#[command] -async fn set_config(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - let mut data = ctx.data.write().await; - let mut config = match data.get_mut::() { - Some(c) => c.lock().await, - None => Err("Could not lock database!".to_string())? - }; - - let key = match args.single::() { - Ok(k) => k, - Err(e) => { msg.reply_ping(&ctx, format!("Error parsing your message: {:?}", e)).await?; Err("ArgParse")? } - }; - - let value = match args.single::() { - Ok(v) => v, - Err(e) => { msg.reply_ping(&ctx, format!("Error parsing your message: {:?}", e)).await?; Err("ArgParse")? } - }; - - match config.config.set(&key, value.clone()) { - Ok(_) => msg.reply_ping(&ctx, format!("Value for key {} set to {}", &key, &value)).await?, - Err(_) => msg.reply_ping(&ctx, format!("Error setting config value")).await?, - }; - - Ok(()) -} - -#[command] -async fn dump_config(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { - let data = &ctx.data.read().await; - let config = match data.get::() { - Some(c) => c.lock().await, - None => Err("Could not lock database!".to_string())? - }; - - msg.reply_ping(&ctx, format!("Config dump; {:?}", &config.config)).await?; - - Ok(()) -} - - -#[command] -async fn get_environment(ctx: &Context, msg: &Message, _: Args) -> CommandResult { - let data = ctx.data.read().await; - let config = match data.get::() { - Some(c) => c.lock().await, - None => Err("Could not lock config!".to_string())? - }; - - msg.reply_ping(&ctx, format!("Currently running under the {} environment", config.get_environment())).await?; - - Ok(()) -} diff --git a/src/error.rs b/src/error.rs index bd83192..29d8153 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,89 +1,2 @@ -use std::error::Error; -use std::fmt; -use std::num::ParseIntError; -use config::ConfigError; -use serenity::prelude::SerenityError; -use reqwest::Error as ReqwestError; - +pub type ManifoldError = Box; pub type ManifoldResult = Result; - -#[derive(Debug, Serialize, Deserialize)] -pub struct ManifoldError { - pub details: String -} - -impl ManifoldError { - pub fn new(msg: &str) -> Self { - Self { - details: msg.to_string() - } - } -} - -impl fmt::Display for ManifoldError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.details) - } -} - -impl Error for ManifoldError { - fn description(&self) -> &str { - &self.details - } -} - -impl From<()> for ManifoldError { - fn from(_err: () ) -> Self { - ManifoldError::new("Unspecified error") - } -} - -impl From for ManifoldError { - fn from(err: r2d2::Error) -> Self { - ManifoldError::new(&err.to_string()) - } -} - -impl From for ManifoldError { - fn from(err: std::io::Error) -> Self { - ManifoldError::new(&err.to_string()) - } -} - -impl From for ManifoldError { - fn from(err: serde_json::error::Error) -> Self { - ManifoldError::new(&err.to_string()) - } -} - -impl From for ManifoldError { - fn from(err: regex::Error) -> Self { - ManifoldError::new(&err.to_string()) - } -} - -impl From<&str> for ManifoldError { - fn from(err: &str) -> Self { - ManifoldError::new(&err.to_string()) - } -} - -impl From for ManifoldError { - fn from(err: SerenityError) -> Self { - ManifoldError::new(&err.to_string()) - } -} - -impl From for ManifoldError { - fn from(err: ReqwestError) -> Self { - ManifoldError::new(&err.to_string()) - } -} - -impl From for ManifoldError { - fn from(err: ConfigError) -> Self { ManifoldError::new(&err.to_string()) } -} - -impl From for ManifoldError { - fn from(err: ParseIntError) -> Self { ManifoldError::new(&err.to_string()) } -} diff --git a/src/events.rs b/src/events.rs index 3a5c5ed..a721179 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,12 +1,16 @@ use std::sync::atomic::AtomicBool; -use serenity::async_trait; -use serenity::model::{ +use poise::{Event, FrameworkContext}; +use poise::futures_util::future::ok; +use poise::serenity_prelude::async_trait; +use poise::serenity_prelude::model::{ gateway::Ready, id::ChannelId, }; -use serenity::prelude::{Context, EventHandler}; -use crate::ManifoldConfig; +use poise::serenity_prelude::{Context, EventHandler, Message}; +use crate::{ManifoldConfig, ManifoldContext, ManifoldData, ManifoldDataInner}; +use crate::error::{ManifoldError, ManifoldResult}; +use crate::models::user::UserInfo; use crate::responses::Responses; @@ -21,26 +25,27 @@ impl Handler { } } - pub async fn standard_startup(ctx: &Context, data_about_bot: Ready) { - let data = ctx.data.read().await; - let config = match data.get::() { - Some(c) => c.lock().await, - None => return - }; + pub async fn listen(ctx: &Context, framework_ctx: FrameworkContext<'_, ManifoldData, ManifoldError>, event: &Event<'_>) -> ManifoldResult<()> { + match event { + Event::Ready { data_about_bot} => Handler::standard_startup(&ctx, &framework_ctx, data_about_bot).await, + Event::Message { new_message } => Handler::message(&ctx, &framework_ctx, &new_message).await, + Event::MessageUpdate { old_if_available, new, event } => Handler::message_edited(&ctx, &framework_ctx, old_if_available, new).await, + _ => Ok(()) + } + } - let responses = match data.get::() { - Some(r) => r.lock().await, - None => return - }; + pub async fn standard_startup(ctx: &Context, framework_ctx: &FrameworkContext<'_, ManifoldData, ManifoldError>, data_about_bot: &Ready) -> ManifoldResult<()> { + let config = &framework_ctx.user_data().await.bot_config; + let responses = &framework_ctx.user_data().await.responses; let greeting = match responses.get_response(&"bot startup".to_string()) { - Some(g) => g.replace("{NAME}", &*ctx.cache.current_user().name).replace("{COFFEE_TYPE}", "espresso").to_string(), + Some(g) => g.to_owned(), None => "Manifold bot connected to discord and ready to begin broadcast operations.".to_string(), }; let bot_nickname = config.get_value(&"BotNickname".to_string()).unwrap_or("BrokenManifoldBot".to_string()); let channel: ChannelId = config.get_channel(&"Log".to_string()).expect("Specified log channel invalid or unavailable"); - for guild in data_about_bot.guilds { + for guild in &data_about_bot.guilds { match guild.id.edit_nickname(&ctx, Some(&*bot_nickname)).await { Ok(()) => (), Err(e) => { @@ -50,12 +55,52 @@ impl Handler { } channel.say(&ctx, greeting).await.expect("Couldn't message log channel!"); - } -} -#[async_trait] -impl EventHandler for Handler { - async fn ready(&self, ctx: Context, data_about_bot: Ready) { - Handler::standard_startup(&ctx, data_about_bot).await; + Ok(()) + } + + async fn message(_ctx: &Context, fctx: &FrameworkContext<'_, ManifoldData, ManifoldError>, msg: &Message) -> ManifoldResult<()> { + let userinfo = &mut fctx.user_data().await.user_info.lock().await; + + if let Some(u) = userinfo.get_mut(&msg.author.id.as_u64()) { + u.last_seen = Some(chrono::Utc::now().timestamp()); + } else { + let new_user = UserInfo { + user_id: msg.author.id.as_u64().clone() as i64, + username: msg.author.name.to_owned(), + weather_location: None, + weather_units: None, + timezone: None, + last_seen: Some(chrono::Utc::now().timestamp()), + }; + + userinfo.insert(msg.author.id.as_u64().clone(), new_user); + } + + Ok(()) + } + + async fn message_edited(ctx: &Context, fctx: &FrameworkContext<'_, ManifoldData, ManifoldError>, original: &Option, new_message: &Option) -> ManifoldResult<()> { + let log_channel = fctx.user_data().await.bot_config.get_channel(&"Log".to_string()).unwrap(); + + if let Some(new) = new_message.as_ref() { + log_channel.send_message(ctx, |f| { + f + .content("") + .embed(|e| { + e + .title("Message updated") + .author(|a| a.name(new.author.name.clone())) + .timestamp(new.timestamp) + .field("Original Content", match original.as_ref() { + Some(m) => m.content.clone(), + None => "Not available".to_string(), + }, false) + .field("New Content", new.content.clone(), false) + }) + }).await?; + } + + Ok(()) } } diff --git a/src/lib.rs b/src/lib.rs index c75a43d..5a09db5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,37 +1,41 @@ #[macro_use] extern crate log; #[macro_use] extern crate serde; -use std::collections::HashSet; use std::env; use std::ops::Deref; use std::path::PathBuf; use std::sync::Arc; -use clap::ArgMatches; -use diesel::r2d2::{ConnectionManager}; -use diesel::SqliteConnection; -use serenity::prelude::*; -use serenity::framework::standard::{*, macros::*}; -use serenity::http::Http; -use serenity::model::prelude::{GuildId, Message, UserId}; -use crate::config::ManifoldConfig; -use crate::error::ManifoldResult; +use clap::ArgMatches; +use diesel::r2d2::ConnectionManager; +use diesel::sqlite::Sqlite; +use diesel::SqliteConnection; +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; +use poise::framework::FrameworkBuilder; +use poise::serenity_prelude::*; + +use crate::config::ManifoldConfig; +use crate::error::{ManifoldError, ManifoldResult}; +use crate::events::Handler; +use crate::models::user::{ManifoldUserInfo, UserInfo}; use crate::responses::Responses; pub mod config; pub mod error; -pub mod responses; pub mod events; -pub mod core_commands; +pub mod responses; +pub mod commands; +pub mod models; +pub mod schema; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); // Retrieve build info from output file pub mod built_info { include!(concat!(env!("OUT_DIR"), "/built.rs")); } -use crate::core_commands::*; - -pub type ManifoldDatabasePool = diesel::r2d2::Pool>; +pub type ManifoldDatabasePool = r2d2::Pool>; pub struct Db { pub pool: ManifoldDatabasePool, @@ -45,29 +49,33 @@ impl Deref for Db { pub struct ManifoldDatabase; -impl TypeMapKey for ManifoldDatabase { - type Value = Arc>; +pub struct ManifoldData(pub Arc); + +impl Deref for ManifoldData { + type Target = ManifoldDataInner; + + fn deref(&self) -> &Self::Target { + &self.0 + } } -impl TypeMapKey for Responses { - type Value = Arc>; +pub struct ManifoldDataInner { + bot_config: ManifoldConfig, + database: Db, + responses: Responses, + user_info: Mutex, } -impl TypeMapKey for ManifoldConfig { - type Value = Arc>; -} +pub type ManifoldContext<'a> = poise::Context<'a, ManifoldData, ManifoldError>; +pub type ManifoldCommand = poise::Command; -#[group] -#[commands(ping, set_config, get_config, dump_config, version, set_activity, get_environment)] -struct Core; - -pub async fn prepare_client(arguments: ArgMatches, mut framework: StandardFramework, event_handler: T, intents: GatewayIntents) -> ManifoldResult { - let bot_environment = arguments.value_of("environment").unwrap_or("Production").to_string(); - let config_file = format!("config/{}", arguments.value_of("config-file").unwrap_or("manifold.yaml")); +pub async fn prepare_client(arguments: ArgMatches, intents: GatewayIntents, injected_commands: Vec) -> ManifoldResult> { + let bot_environment = arguments.get_one("environment").unwrap(); + let config_file = format!("config/{}", arguments.get_one::("config-file").unwrap()); info!("Reading configuration..."); debug!("Configuration file path: {}", &config_file); - let config = ManifoldConfig::load_config(&config_file, &bot_environment).expect(&*format!("Could not read configuration file {}", &config_file)); + let config = ManifoldConfig::load_config(&config_file, bot_environment).expect(&*format!("Could not read configuration file {}", &config_file)); let prefix = config.get_value(&"BotPrefix".to_string()).expect("Could not read bot_prefix from config."); @@ -80,110 +88,51 @@ pub async fn prepare_client(arguments: ArgMatches, mu let mut responses = Responses::new(); responses.reload(&PathBuf::from(config.get_value(&"ResponsesFilePath".to_string()).unwrap_or("txt/responses.txt".to_string()))).expect("Could not load responses file!"); - let token = env::var("DISCORD_TOKEN").expect( "Could not find an environment variable called DISCORD_TOKEN", ); - let application_id: u64 = env::var("APPLICATION_ID").expect( - "Could not find an application ID in the APPLICATION_ID environment variable, please provide one", - ).parse().expect("That wasn't a number"); + let framework = poise::Framework::builder() + .options(poise::FrameworkOptions { + event_handler: |ctx, e, fctx, _| Box::pin(async move { + Handler::listen(ctx, fctx, e).await + }), + pre_command: |ctx: ManifoldContext<'_>| Box::pin(async move { + info!("Received command {} from {}", ctx.command().name, ctx.author().name); + let config = &ctx.data().bot_config; + let log_channel = config.get_channel(&"Log".to_string()).unwrap(); + let _ = log_channel.say(ctx, format!("Received command {} from {}", ctx.command().name, ctx.author().name)).await; + }), + commands: commands::collect_commands(injected_commands), + prefix_options: poise::PrefixFrameworkOptions { + prefix: Some(prefix), + ..Default::default() + }, + ..Default::default() + }) + .token(token) + .intents(intents) + .setup(|ctx, _ready, framework| { + Box::pin(async move { + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + ctx.set_activity(Activity::watching("you")).await; + let db = Db { pool }; + apply_migrations(&mut db.get()?); + let user_info = UserInfo::load(&db).expect("Could not load user info, rejecting"); + Ok(ManifoldData(Arc::new(ManifoldDataInner { + bot_config: config, + database: db, + responses, + user_info: Mutex::new(user_info), + }))) + }) + }); - let http = Http::new(&token); - - let owners = match http.get_current_application_info().await { - Ok(info) => { - let mut owners = HashSet::new(); - owners.insert(info.owner.id); - - owners - } - Err(why) => panic!("Could not get HTTP application information - exiting: {:?}", why), - }; - - framework = framework.configure(|c| c - .with_whitespace(true) - .prefix(&prefix) - .owners(owners) - .case_insensitivity(true) - ).before(before) - .help(&MANIFOLD_HELP); - framework.group_add(&CORE_GROUP); - - let client = Client::builder(&token, intents) - .event_handler(event_handler) - .application_id(application_id) - .framework(framework) - .await - .expect("Error creating client!"); - { - let mut data = client.data.write().await; - let db = Db { pool }; - - data.insert::(Arc::new(Mutex::new(config))); - data.insert::(Arc::new(Mutex::new(db))); - data.insert::(Arc::new(Mutex::new(responses))); - } - - Ok(client) + Ok(framework) } -#[help] -async fn manifold_help( - ctx: &Context, - msg: &Message, - args: Args, - help_options: &'static HelpOptions, - groups: &[&'static CommandGroup], - owners: HashSet -) -> CommandResult { - let _ = help_commands::with_embeds(ctx, msg, args, help_options, groups, owners).await; - Ok(()) -} - -#[check] -#[name = "ModOrHigher"] -async fn mod_or_higher_check(ctx: &Context, msg: &Message, _: &mut Args, _: &CommandOptions) -> Result<(), Reason> { - let data = ctx.data.read().await; - let config = match data.get::() { - Some(c) => c.lock().await, - None => return Err(Reason::Log("Couldn't lock config".to_string())) - }; - - let role_guild_config = match config.get_value(&"RoleGuild".to_string()) { - Ok(rgc) => rgc, - Err(_) => { - error!("Guild not configured"); - return Err(Reason::Unknown); - } - }; - - let role_guild_number = match role_guild_config.parse::() { - Ok(rgn) => rgn, - Err(e) => { - error!("Parse GuildId fail"); - return Err(Reason::Log(e.to_string())); - } - }; - - let guild_id = GuildId(role_guild_number); - let administrator_role = match config.get_role(&"Admin".to_string()) { - Ok(r) => r, - Err(e) => { - error!("Get admin role from config failed: {:?}", e); - return Err(Reason::Log(e.to_string())); - } - }; - - if !msg.author.has_role(&ctx, guild_id, administrator_role).await.unwrap_or(false) { - return Err(Reason::User("User not administrative".to_string())); - } - - Ok(()) -} - -#[hook] -async fn before(_: &Context, msg: &Message, command_name: &str) -> bool { - info!("Received command '{}' from user '{}'", command_name, msg.author.name); - true +fn apply_migrations(conn: &mut impl MigrationHarness) { + + conn.run_pending_migrations(MIGRATIONS) + .expect("An error occurred applying migrations."); } diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..fbfeedb --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod user; +pub mod weather; \ No newline at end of file diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..4fbae60 --- /dev/null +++ b/src/models/user.rs @@ -0,0 +1,62 @@ +use diesel::prelude::*; +use std::collections::HashMap; +use diesel::{insert_into, update}; + +use crate::schema::*; +use crate::schema::userinfo::dsl as userinfo_dsl; +use crate::Db; +use crate::error::{ManifoldError, ManifoldResult}; + +#[derive(Identifiable, Insertable, AsChangeset, Debug, Queryable, Serialize, Clone)] +#[diesel(primary_key(user_id))] +#[diesel(table_name = userinfo)] +pub struct UserInfo { + pub(crate) user_id: i64, + pub username: String, + pub weather_location: Option, + pub weather_units: Option, + pub timezone: Option, + pub last_seen: Option, +} + +pub type ManifoldUserInfo = HashMap; + +impl UserInfo { + pub fn load(conn: &Db) -> ManifoldResult { + let data = userinfo_dsl::userinfo + .get_results::(&mut conn.get()?); + + debug!("{:?}", data); + + let mut loaded_userinfo = ManifoldUserInfo::new(); + + match data { + Ok(d) => { + for user in d { + loaded_userinfo.insert(user.user_id as u64, user.clone()); + }; + } + Err(e) => { + error!("Couldn't load user data! {:?}", e); + Err("Couldn't load user data")? + } + }; + + Ok(loaded_userinfo) + } + + pub fn insert(&self, conn: &Db) -> ManifoldResult { + insert_into(userinfo_dsl::userinfo) + .values(self) + .execute(&mut conn.get()?) + .map_err(|e| ManifoldError::from(e)) + } + + pub fn save(&self, conn: &Db) -> ManifoldResult { + update(userinfo_dsl::userinfo) + .filter(userinfo::user_id.eq(self.user_id)) + .set(self) + .execute(&mut conn.get()?) + .map_err(|e| ManifoldError::from(e)) + } +} diff --git a/src/models/weather.rs b/src/models/weather.rs new file mode 100644 index 0000000..507f758 --- /dev/null +++ b/src/models/weather.rs @@ -0,0 +1,186 @@ +use crate::error::ManifoldResult; +use reqwest::Client; +use reqwest::header::CONTENT_TYPE; + +pub struct Weather { + base_url: String, + api_key: String, +} + +#[derive(Debug, Deserialize)] +pub struct CurrentConditionInformation { + pub text: String, + pub icon: String, + pub code: i64, +} + +#[derive(Debug, Deserialize)] +pub struct CurrentWeatherInformation { + pub last_updated: String, + pub last_updated_epoch: i64, + pub temp_c: f64, + pub temp_f: f64, + pub feelslike_c: f64, + pub feelslike_f: f64, + pub condition: CurrentConditionInformation, + pub wind_mph: f64, + pub wind_kph: f64, + pub wind_degree: i64, + pub wind_dir: String, + pub pressure_mb: f64, + pub pressure_in: f64, + pub precip_mm: f64, + pub precip_in: f64, + pub humidity: i64, + pub cloud: i64, + pub is_day: i64, + pub uv: f64, + pub gust_mph: f64, + pub gust_kph: f64, + pub dewpoint_c: Option, + pub dewpoint_f: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ForecastHour { + pub time_epoch: i64, + pub time: String, + pub temp_c: f64, + pub temp_f: f64, + pub condition: CurrentConditionInformation, + pub wind_mph: f64, + pub wind_kph: f64, + pub wind_degree: i64, + pub wind_dir: String, + pub pressure_mb: f64, + pub pressure_in: f64, + pub precip_mm: f64, + pub precip_in: f64, + pub humidity: i64, + pub cloud: i64, + pub feelslike_c: f64, + pub feelslike_f: f64, + pub windchill_c: f64, + pub windchill_f: f64, + pub heatindex_c: f64, + pub heatindex_f: f64, + pub dewpoint_c: f64, + pub dewpoint_f: f64, + pub will_it_rain: i64, + pub will_it_snow: i64, + pub is_day: i64, + pub vis_km: f64, + pub vis_miles: f64, + pub chance_of_rain: i64, + pub chance_of_snow: i64, + pub gust_mph: f64, + pub gust_kph: f64, +} + +#[derive(Debug, Deserialize)] +pub struct DayWeather { + pub maxtemp_c: f64, + pub maxtemp_f: f64, + pub mintemp_c: f64, + pub mintemp_f: f64, + pub avgtemp_c: f64, + pub avgtemp_f: f64, + pub maxwind_mph: f64, + pub maxwind_kph: f64, + pub totalprecip_mm: f64, + pub totalprecip_in: f64, + pub avgvis_km: f64, + pub avgvis_miles: f64, + pub avghumidity: f64, + pub condition: CurrentConditionInformation, + pub uv: f64, +} + +#[derive(Debug, Deserialize)] +pub struct DayAstro { + pub sunrise: String, + pub sunset: String, + pub moonrise: String, + pub moonset: String, + pub moon_phase: String, + pub moon_illumination: String +} + +#[derive(Debug, Deserialize)] +pub struct ForecastDayWrapper { + pub forecastday: Vec +} + +#[derive(Debug, Deserialize)] +pub struct ForecastDay { + pub date: String, + pub date_epoch: i64, + pub day: DayWeather, + pub astro: DayAstro, + pub hour: Vec +} + +#[derive(Debug, Deserialize)] +pub struct LocationInformation { + pub name: String, + pub region: String, + pub country: String, + pub lat: f64, + pub lon: f64, + pub tz_id: String, + pub localtime_epoch: i64, + pub localtime: String, +} + +#[derive(Debug, Deserialize)] +pub struct WeatherRequestResponse { + pub location: LocationInformation, + pub current: CurrentWeatherInformation, +} + +#[derive(Debug, Deserialize)] +pub struct WeatherForecastRequestResponse { + pub location: LocationInformation, + pub current: CurrentWeatherInformation, + pub forecast: ForecastDayWrapper, +} + +impl Weather { + pub fn new(api_key:String, base_url: String) -> Self { + Weather { + base_url, + api_key, + } + } + + pub async fn get_weather_forecast(&self, target: String, days: Option) -> ManifoldResult { + let target_url: String; + + if let Some(d) = days { + target_url = format!("{}/forecast.json?key={}&q={}&days={}", self.base_url, self.api_key, &target, d); + } else { + target_url = format!("{}/forecast.json?key={}&q={}&days=3", self.base_url, self.api_key, &target); + } + + let client = Client::new(); + let result = client.get(target_url) + .header(CONTENT_TYPE, "application/json") + .send() + .await? + .text() + .await?; + + debug!("{:?}", result); + + let mut decoded_result: WeatherForecastRequestResponse = serde_json::from_str(&*result)?; + + let temp = decoded_result.current.temp_c; let humid = decoded_result.current.humidity as f64; + decoded_result.current.dewpoint_c = Some(&temp - (14.55 + 0.114 * &temp) * (1.0 - (0.01 * &humid)) - num::pow((2.5 + 0.007 * &temp) * (1.0 - (0.01 * &humid)),3) - (15.9 + 0.117 * &temp) * num::pow(1.0 - (0.01 * &humid), 14)); + decoded_result.current.dewpoint_f = Some((decoded_result.current.dewpoint_c.unwrap() * 1.8) + 32.0); + + + debug!("{:?}", decoded_result); + + Ok(decoded_result) + } +} diff --git a/src/responses.rs b/src/responses.rs index 7c29567..72429d5 100644 --- a/src/responses.rs +++ b/src/responses.rs @@ -8,6 +8,7 @@ use rand::seq::SliceRandom; use crate::error::ManifoldResult; use std::io::{BufReader, BufRead}; +#[derive(Debug)] pub struct Responses { file_content: HashMap> } diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..d4f9029 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,12 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + userinfo (user_id) { + user_id -> BigInt, + username -> Text, + weather_location -> Nullable, + weather_units -> Nullable, + timezone -> Nullable, + last_seen -> Nullable, + } +}