Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add poise and some basic commands #1

Merged
merged 4 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
#.idea/

# Editor specific
# vscode settings and config
.vscode
6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions rustfmt.toml
Original file line number Diff line number Diff line change
@@ -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
161 changes: 161 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
use arnak::{Collection, CollectionItemBrief, CollectionItemType, CollectionQueryParams, GameQueryParams};
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<String>,
) -> 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 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(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?;
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::<Vec<String>>()
.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<CollectionItemBrief>,
) -> Vec<CollectionItemBrief> {
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
}
89 changes: 71 additions & 18 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error + Send + Sync>;
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::<Data, Error> {
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:?}");
}
}
Loading