From 55f35899b7177f198a35d224e3d0e452d92e06eb Mon Sep 17 00:00:00 2001 From: Matthew Thompson Date: Tue, 27 Aug 2024 20:00:15 +0100 Subject: [PATCH 1/4] Add .vscode to gitignore --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d01bd1a..b1649a4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,8 @@ Cargo.lock # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +#.idea/ + +# Editor specific +# vscode settings and config +.vscode From 7dad47d6ebc8eabd7fff76a50f432300e8c6576d Mon Sep 17 00:00:00 2001 From: Matthew Thompson Date: Tue, 27 Aug 2024 21:27:34 +0100 Subject: [PATCH 2/4] Add rust format config --- rustfmt.toml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 rustfmt.toml diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..364e87a --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,9 @@ +comment_width = 100 +group_imports = "StdExternalCrate" +imports_granularity = "Module" +match_block_trailing_comma = true +newline_style = "Unix" +normalize_comments = true +normalize_doc_attributes = true +trailing_comma = "Vertical" +wrap_comments = true From 4840cee6c2964a210f2d5f35e9e88408f9404ca9 Mon Sep 17 00:00:00 2001 From: Matthew Thompson Date: Tue, 27 Aug 2024 21:29:10 +0100 Subject: [PATCH 3/4] Add some basic poise slash commands --- Cargo.toml | 6 +- README.md | 8 ++- src/commands.rs | 143 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 89 ++++++++++++++++++++++++------ 4 files changed, 224 insertions(+), 22 deletions(-) create mode 100644 src/commands.rs diff --git a/Cargo.toml b/Cargo.toml index cd5c7b2..4fafc7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,6 @@ license = "MIT" repository = "https://github.com/MatthewThompson/BlueBGG" [dependencies] -arnak = "0.1.0" -serenity = "0.12" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +arnak = { path = "../arnak" } +poise = "0.6.1" +tokio = "1" diff --git a/README.md b/README.md index 64b41d6..8c90494 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ # BlueBGG -Discord bot for interacting with the BoardGameGeek API. +Discord bot for interacting with the BoardGameGeek API, making use of [arnak](https://github.com/MatthewThompson/arnak/) to access the API. + +Currently a work in progress, with just some dummy features to test it works end to end. +Some planned features: + - Search owned games under specific criteria such as min/max rating, player count, min/max time, genre. + - Store a mapping of discord to bgg username so you can @people and get games that a group owns. + - Information on specific games with links to BGG. diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..feee804 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,143 @@ +use arnak::{Collection, CollectionItemBrief, CollectionItemType, CollectionQueryParams}; +use poise::serenity_prelude::{ + Colour, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter, Timestamp, +}; +use poise::CreateReply; + +use crate::{Context, Error}; + +/// Show this help menu +#[poise::command(track_edits, slash_command)] +pub async fn help( + ctx: Context<'_>, + #[description = "Specific command to show help about"] + #[autocomplete = "poise::builtins::autocomplete_command"] + command: Option, +) -> Result<(), Error> { + println!("help command"); + poise::builtins::help( + ctx, + command.as_deref(), + poise::builtins::HelpConfiguration { + extra_text_at_bottom: "Test help text idk...", + ..Default::default() + }, + ) + .await?; + Ok(()) +} + +// Creates an embed with the default fields set. +// +// This sets the title to the bot name, and adds the footer. +fn create_base_embed() -> CreateEmbed { + CreateEmbed::new() + .footer(CreateEmbedFooter::new( + "Bot source: https://github.com/MatthewThompson/BlueBGG", + )) + .colour(Colour::from_rgb(0, 176, 255)) + .timestamp(Timestamp::now()) +} + +/// Get info for a particular game, by its ID. +#[poise::command(track_edits, slash_command)] +pub async fn game_info( + ctx: Context<'_>, + #[description = "An ID of a game in the BGG database."] game_id: u64, +) -> Result<(), Error> { + let reply_content = format!("Getting game {}...Not yet implemented", game_id); + let embed = create_base_embed() + .author(CreateEmbedAuthor::new("Some game")) + .description(&reply_content); + let reply = CreateReply::default().embed(embed); + + ctx.send(reply).await?; + Ok(()) +} + +/// Get the 10 games that a given user has rated highest. +#[poise::command(track_edits, slash_command)] +pub async fn top10( + ctx: Context<'_>, + #[description = "A board game geek's user to get the top 10 rated games for."] + #[autocomplete = "poise::builtins::autocomplete_command"] + bgg_user: String, +) -> Result<(), Error> { + let params = CollectionQueryParams::new() + .item_type(CollectionItemType::BoardGame) + .exclude_item_type(CollectionItemType::BoardGameExpansion); + + let user_collection = ctx + .data() + .board_game_api + .collection_brief() + .get_from_query(&bgg_user, params) + .await; + let games = match user_collection { + Err(arnak::Error::UnknownUsernameError) => { + ctx.reply(format!("User {} not found", bgg_user)).await?; + return Ok(()); + }, + Err(arnak::Error::MaxRetryError(_)) => { + ctx.reply(format!( + "Data for {} from BGG not yet ready, please try again shortly", + bgg_user + )) + .await?; + return Ok(()); + }, + Err(e) => { + println!("{:?}", e); + ctx.reply(format!( + "Unexpected error requesting user {} collection", + bgg_user + )) + .await?; + return Ok(()); + }, + Ok(user_collection) => get_games_by_user_rating_desc(user_collection), + }; + + let reply_content = games + .iter() + .take(10) + .map(|game| { + let user_rating = match game.stats.rating.user_rating { + None => String::from("Unrated"), + Some(rating) => rating.to_string(), + }; + format!( + "- [{}](https://boardgamegeek.com/boardgame/{}) - ({})\n", + game.name, game.id, user_rating + ) + }) + .collect::>() + .join("\n"); + + let embed = create_base_embed() + .author( + CreateEmbedAuthor::new(format!("{}'s top 10", bgg_user)).url(format!( + "https://boardgamegeek.com/collection/user/{}", + bgg_user + )), + ) + .description(&reply_content); + let reply = CreateReply::default().embed(embed); + + ctx.send(reply).await?; + Ok(()) +} + +fn get_games_by_user_rating_desc( + user_collection: Collection, +) -> Vec { + let mut games = user_collection.items; + games.sort_unstable_by(|b, a| { + a.stats + .rating + .user_rating + .partial_cmp(&b.stats.rating.user_rating) + .unwrap() + }); + games +} diff --git a/src/main.rs b/src/main.rs index 04244c9..5e24bec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,37 +1,90 @@ +mod commands; + use std::env; -use serenity::async_trait; -use serenity::model::channel::Message; -use serenity::prelude::*; +use arnak::BoardGameGeekApi; +use poise::serenity_prelude as serenity; + +// Custom user data passed to all command functions +pub struct Data { + board_game_api: BoardGameGeekApi, +} -struct Handler; +// Types used by all command functions +type Error = Box; +type Context<'a> = poise::Context<'a, Data, Error>; -#[async_trait] -impl EventHandler for Handler { - async fn message(&self, ctx: Context, msg: Message) { - if msg.content == "!ping" { - if let Err(why) = msg.channel_id.say(&ctx.http, "Pong!").await { - println!("Error sending message: {why:?}"); +async fn on_error(error: poise::FrameworkError<'_, Data, Error>) { + // This is our custom error handler + // They are many errors that can occur, so we only handle the ones we want to customize + // and forward the rest to the default handler + match error { + poise::FrameworkError::Setup { error, .. } => panic!("Failed to start bot: {:?}", error), + poise::FrameworkError::Command { error, ctx, .. } => { + println!("Error in command `{}`: {:?}", ctx.command().name, error,); + }, + error => { + if let Err(e) = poise::builtins::on_error(error).await { + println!("Error while handling error: {}", e) } - } + }, } } #[tokio::main] async fn main() { + let options = poise::FrameworkOptions:: { + commands: vec![commands::help(), commands::game_info(), commands::top10()], + // The global error handler for all error cases that may occur + on_error: |error| Box::pin(on_error(error)), + // This code is run before every command + pre_command: |ctx| { + Box::pin(async move { + println!("Executing command {}...", ctx.command().qualified_name); + }) + }, + // This code is run after a command if it was successful (returned Ok) + post_command: |ctx| { + Box::pin(async move { + println!("Executed command {}!", ctx.command().qualified_name); + }) + }, + event_handler: |_ctx, event, _framework, _data| { + Box::pin(async move { + println!( + "Got an event in event handler: {:?}", + event.snake_case_name() + ); + Ok(()) + }) + }, + ..Default::default() + }; + let framework = poise::Framework::builder() + .options(options) + .setup(move |ctx, _, framework| { + Box::pin(async move { + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + println!("setup successfully"); + Ok(Data { + board_game_api: BoardGameGeekApi::new(), + }) + }) + }) + .build(); + // Login with a bot token from the environment let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); // Set gateway intents, which decides what events the bot will be notified about - let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT; + let intents = serenity::GatewayIntents::MESSAGE_CONTENT; - // Create a new instance of the Client, logging in as a bot. - let mut client = Client::builder(&token, intents) - .event_handler(Handler) + let mut client = serenity::ClientBuilder::new(token, intents) + .framework(framework) .await - .expect("Err creating client"); + .expect("Error creating client"); // Start listening for events by starting a single shard - if let Err(why) = client.start().await { - println!("Client error: {why:?}"); + if let Err(error) = client.start().await { + println!("Error starting client: {error:?}"); } } From 21a67b8192f3de92cebaf5a1808e5799853e4a1e Mon Sep 17 00:00:00 2001 From: Matthew Thompson Date: Fri, 30 Aug 2024 00:37:47 +0100 Subject: [PATCH 4/4] Add game_into command --- src/commands.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index feee804..6790b93 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,4 +1,4 @@ -use arnak::{Collection, CollectionItemBrief, CollectionItemType, CollectionQueryParams}; +use arnak::{Collection, CollectionItemBrief, CollectionItemType, CollectionQueryParams, GameQueryParams}; use poise::serenity_prelude::{ Colour, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter, Timestamp, }; @@ -45,10 +45,28 @@ pub async fn game_info( ctx: Context<'_>, #[description = "An ID of a game in the BGG database."] game_id: u64, ) -> Result<(), Error> { - let reply_content = format!("Getting game {}...Not yet implemented", game_id); + let game = ctx.data().board_game_api.game().get_by_id(game_id, GameQueryParams::new()).await; + let game = match game { + Ok(game) => game, + e => { + println!("oops: {:?}", e); + return Ok(()); + }, + }; + + let description_truncated: String = game.description.chars().take(242).collect(); + let embed_description: String = match description_truncated.len() { + 0..=280 => description_truncated, + _ => format!("{}...", description_truncated.trim_end()), + }; + let embed = create_base_embed() - .author(CreateEmbedAuthor::new("Some game")) - .description(&reply_content); + .author(CreateEmbedAuthor::new(game.name).url(format!("https://boardgamegeek.com/boardgame/{}", game.id))) + .description(embed_description) + .thumbnail(game.thumbnail) + .field("Rating", format!("{:.1}", game.stats.average_rating), true) + .field("Player Count", format!("{}-{}", game.min_players, game.max_players), true) + .field("Playing Time (mins)", format!("{}-{}", game.min_playtime.num_minutes(), game.max_playtime.num_minutes()), true); let reply = CreateReply::default().embed(embed); ctx.send(reply).await?;