From d06686dd4acbb0dabe60fca41f78627c0714a73f Mon Sep 17 00:00:00 2001 From: Joey Hines Date: Fri, 25 Apr 2025 19:16:18 -0600 Subject: [PATCH] Add movie commands --- Cargo.lock | 3 +- Cargo.toml | 3 +- src/config.rs | 1 + src/discord/admin.rs | 10 ++++ src/discord/mod.rs | 6 ++ src/discord/movie.rs | 137 +++++++++++++++++++++++++++++++++++++++++++ src/models/mod.rs | 1 + src/models/movie.rs | 94 +++++++++++++++++++++++++++++ 8 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 src/discord/movie.rs create mode 100644 src/models/movie.rs diff --git a/Cargo.lock b/Cargo.lock index 9cecc4e..b58107a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1093,7 +1093,7 @@ dependencies = [ [[package]] name = "fren" -version = "1.5.0" +version = "1.6.0" dependencies = [ "axum 0.8.1", "base64 0.22.1", @@ -1120,6 +1120,7 @@ dependencies = [ "structopt", "symphonia", "tera", + "thiserror 2.0.12", "tokio", "tonic", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 913311f..9ff063e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fren" -version = "1.5.0" +version = "1.6.0" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -31,6 +31,7 @@ poise = "0.6.1" tracing-subscriber = "0.3.19" log = "0.4.26" cta-api = { version = "0.2.0", registry = "ahines"} +thiserror = "2.0.12" [dependencies.tokio] version = "1.35.1" diff --git a/src/config.rs b/src/config.rs index 8d706a1..1711f4f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,6 +44,7 @@ pub struct BotConfig { pub effect_role_duration: i64, pub raas_server: String, pub cta_key: String, + pub omdb_key: String, pub picox: PicOxConfig, } diff --git a/src/discord/admin.rs b/src/discord/admin.rs index eb0f725..672aa8c 100644 --- a/src/discord/admin.rs +++ b/src/discord/admin.rs @@ -18,6 +18,7 @@ use poise::serenity_prelude::{ FormattedTimestampStyle, MessageBuilder, Timestamp, UserId, }; use std::borrow::Cow; +use crate::models::movie::Movie; pub async fn is_admin(ctx: Context<'_>) -> Result { Ok(ctx.data().cfg.admins.contains(&ctx.author().id)) @@ -337,3 +338,12 @@ pub async fn list_social_credit_phrases(ctx: Context<'_>) -> Result<(), Error> { Ok(()) } + +/// Remove a movie from the library +#[poise::command(prefix_command, category = "Admin", check = "is_admin")] +pub async fn remove_movie(ctx: Context<'_>, #[description = "Name of the movie"] movie: String,) -> Result<(), Error> { + Movie::remove_movie(&ctx.data().db, &movie)?; + + ctx.reply(format!("Removed *{}* from the library", movie)).await?; + Ok(()) +} diff --git a/src/discord/mod.rs b/src/discord/mod.rs index a4ede41..0843b8d 100644 --- a/src/discord/mod.rs +++ b/src/discord/mod.rs @@ -8,6 +8,7 @@ mod fren_coin; mod joke; mod little_fren; mod motivate; +mod movie; mod role; pub(crate) mod shop; mod transit; @@ -304,6 +305,7 @@ pub async fn run_bot(global_data: GlobalData) { admin::add_social_credit_phrase(), admin::remove_social_credit_phrase(), admin::list_social_credit_phrases(), + admin::remove_movie(), album::add_image(), album::list_albums(), birthday::add_birthday(), @@ -336,6 +338,10 @@ pub async fn run_bot(global_data: GlobalData) { motivate::motivation(), motivate::motivation_add_album(), motivate::green_screen(), + movie::add_movie(), + movie::list_movies(), + movie::rate_movie(), + movie::movie(), role::list_roles(), role::join_role(), role::leave_role(), diff --git a/src/discord/movie.rs b/src/discord/movie.rs new file mode 100644 index 0000000..60a5659 --- /dev/null +++ b/src/discord/movie.rs @@ -0,0 +1,137 @@ +use crate::discord::Context; +use crate::error::Error; +use crate::models::movie::{LOWER_RATING, Movie, UPPER_RATING}; +use poise::CreateReply; +use poise::serenity_prelude::{Attachment, Color, CreateEmbed, Mentionable, MessageBuilder}; +use reqwest::Client; +use serde::Deserialize; + +/// Add a movie to the library +#[poise::command(prefix_command, category = "Movies")] +pub async fn add_movie( + ctx: Context<'_>, + #[description = "Name of the movie"] movie: String, + #[description = "What to give the rating out of. e.g ⭐"] rating: String, + #[description = "Movie Poster"] movie_poser: Attachment, +) -> Result<(), Error> { + let data = movie_poser.download().await?; + + let poster = ctx + .data() + .picox + .add_image( + "MoviePosters", + vec![movie.clone()], + &movie_poser.filename, + data, + ) + .await?; + + let movie = Movie::add_movie(&ctx.data().db, movie, rating, poster.link)?; + + ctx.reply(format!( + "`{}` has been added to the Fleecebuster Hall of Fame", + movie.name + )) + .await?; + + Ok(()) +} + +/// Rate a movie in the library +/// +/// Ratings are from -10.0 to 10.0. +/// * Movies rated -10.0 are terrible but super enjoyable +/// * Movies rated 0.0 are bad and boring, a true sin +/// * Movies rated 10.0 are really good +#[poise::command(prefix_command, category = "Movies")] +pub async fn rate_movie( + ctx: Context<'_>, + #[description = "Name of the movie"] movie: String, + #[description = "Rating to give the movie from -10.0 to 10.0"] rating: f32, +) -> Result<(), Error> { + if !(LOWER_RATING..=UPPER_RATING).contains(&rating) { + ctx.reply("Ratings must be from -10.0 to 10.0!").await?; + + return Ok(()); + } + + Movie::add_rating(&ctx.data().db, &movie, ctx.author().id, rating)?; + + ctx.reply("Your rating has been added!").await?; + + Ok(()) +} + +pub const OMDB_URL: &str = "https://www.omdbapi.com/"; + +#[derive(Deserialize)] +#[allow(dead_code)] +pub struct MovieResp { + #[serde(rename = "Title")] + title: String, + #[serde(rename = "Year")] + year: String, + #[serde(rename = "Plot")] + plot: String, +} + +/// Get information on a movie +#[poise::command(prefix_command, category = "Movies")] +pub async fn movie( + ctx: Context<'_>, + #[description = "Name of the movie"] #[rest] movie: String, +) -> Result<(), Error> { + let movie = Movie::get_movie(&ctx.data().db, &movie)?; + + let client = Client::new(); + + let metadata: MovieResp = client + .get(OMDB_URL) + .query(&[("t", movie.name.clone()), ("apikey", ctx.data().cfg.omdb_key.clone())]) + .send() + .await? + .json() + .await?; + + let mut ratings = MessageBuilder::new(); + + for (user, rating) in &movie.ratings { + ratings.push_line(format!("* {}: {} {}", user.mention(), rating, &movie.rating_object)); + } + + ctx.send( + CreateReply::default().embed( + CreateEmbed::new() + .title(format!("{} ({})", movie.name, metadata.year)) + .description(metadata.plot) + .image(movie.poster.clone()) + .field("Flock Rating", format!("{} {}", movie.calculate_rating(), movie.rating_object), true) + .field("Ratings:", ratings.build(), false) + .color(Color::from_rgb(0x11, 0x40, 0xaa)) + ), + ) + .await?; + + Ok(()) +} + +/// List all the movies in the library +#[poise::command(prefix_command, category = "Movies")] +pub async fn list_movies(ctx: Context<'_>) -> Result<(), Error> { + let movies = Movie::get_movies(&ctx.data().db)?; + + let mut msg_builder = MessageBuilder::new(); + + msg_builder.push_line("# Fleecebuster Movies:"); + + for movie in &movies { + msg_builder.push_line(format!("* *{}* {} {}", movie.name, movie.calculate_rating(), movie.rating_object)); + } + + ctx.reply(msg_builder.build()).await?; + + Ok(()) +} + + diff --git a/src/models/mod.rs b/src/models/mod.rs index 6c3a3ff..284a94c 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -4,6 +4,7 @@ pub mod insult_compliment; pub mod lil_fren; pub mod managed_roles; pub mod motivation; +pub mod movie; pub mod race; pub mod random; pub mod social_credit; diff --git a/src/models/movie.rs b/src/models/movie.rs new file mode 100644 index 0000000..0b172f1 --- /dev/null +++ b/src/models/movie.rs @@ -0,0 +1,94 @@ +use crate::error::Error; +use j_db::database::Database; +use j_db::error::JDbError; +use poise::serenity_prelude::UserId; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +pub const LOWER_RATING: f32 = -10.0; +pub const UPPER_RATING: f32 = 10.0; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Movie { + id: Option, + pub name: String, + pub rating_object: String, + pub poster: Url, + pub ratings: HashMap, +} + +impl j_db::model::JdbModel for Movie { + fn id(&self) -> Option { + self.id + } + + fn set_id(&mut self, id: u64) { + self.id = Some(id) + } + + fn tree() -> String { + "Movie".to_string() + } + + fn check_unique(&self, other: &Self) -> bool { + !self.name.eq_ignore_ascii_case(&other.name) + } +} + +impl Movie { + pub fn add_movie( + db: &Database, + name: String, + rating_object: String, + poster: Url, + ) -> Result { + Ok(db.insert(Self { + id: None, + name, + rating_object, + poster, + ratings: Default::default(), + })?) + } + + pub fn get_movie(db: &Database, name: &str) -> Result { + Ok(db + .filter(|_, movie: &Movie| movie.name.eq_ignore_ascii_case(name))? + .next() + .ok_or(JDbError::NotFound)?) + } + + pub fn get_movies(db: &Database) -> Result, Error> { + Ok(db + .filter(|_, _movie: &Movie| true)? + .collect()) + } + + pub fn remove_movie(db: &Database, name: &str) -> Result { + let movie = Self::get_movie(db, name)?; + + db.remove::(movie.id.unwrap())?; + + Ok(movie) + } + + pub fn add_rating(db: &Database, name: &str, user: UserId, rating: f32) -> Result<(), Error> { + let mut movie = Self::get_movie(db, name)?; + + movie.ratings.insert(user, rating); + + db.insert(movie)?; + + Ok(()) + } + + pub fn calculate_rating(&self) -> f32 { + if self.ratings.is_empty() { + 0.0 + } + else { + self.ratings.values().copied().sum::() / self.ratings.len() as f32 + } + } +}