Initial commit

+ Working color and album commands
+ Clippy + fmt
This commit is contained in:
Joey Hines 2022-11-12 14:39:54 -07:00
commit ae8b513cdf
Signed by: joeyahines
GPG Key ID: 995E531F7A569DDB
10 changed files with 2580 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
/.idea
config.toml

2064
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

22
Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "fren"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
config = "0.13.2"
structopt = "0.3.26"
reqwest = "0.11.12"
serde = "1.0.147"
toml = "0.5.9"
rand = "0.8.5"
[dependencies.serenity]
version = "0.11.5"
features = ["framework", "standard_framework", "rustls_backend"]
[dependencies.tokio]
version = "1.0"
features = ["macros", "rt-multi-thread"]

54
src/config.rs Normal file
View File

@ -0,0 +1,54 @@
use config::{Config, File};
use serde::{Deserialize, Serialize};
use serenity::prelude::TypeMapKey;
use std::path::{Path, PathBuf};
use structopt::StructOpt;
#[derive(Debug, StructOpt)]
#[structopt(name = "fren", about = "Friend Bot")]
pub struct Args {
pub cfg_path: PathBuf,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AlbumConfig {
pub name: String,
pub album_id: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct BotConfig {
pub bot_token: String,
pub imgur_client_id: String,
#[serde(default)]
pub albums: Vec<AlbumConfig>,
}
impl BotConfig {
pub fn new(config_path: &Path) -> Result<Self, config::ConfigError> {
let cfg = Config::builder()
.add_source(File::from(config_path))
.build()?;
cfg.try_deserialize()
}
pub async fn save(&self, config_path: &Path) -> Result<(), tokio::io::Error> {
let output_str = toml::to_string_pretty(&self).unwrap();
tokio::fs::write(config_path, output_str).await?;
Ok(())
}
}
#[derive(Debug)]
pub struct GlobalData {
pub args: Args,
pub cfg: BotConfig,
}
impl TypeMapKey for GlobalData {
type Value = GlobalData;
}

95
src/discord/album.rs Normal file
View File

@ -0,0 +1,95 @@
use crate::config::AlbumConfig;
use crate::{command, group, GlobalData};
use serenity::client::Context;
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
#[group]
#[commands(add_album, remove_album, list_albums)]
pub struct Album;
#[command]
#[only_in(guilds)]
#[min_args(2)]
#[max_args(2)]
async fn add_album(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let album_name = args.parse::<String>()?;
args.advance();
let album_id = args.parse::<String>()?;
let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap();
global_data.cfg.albums.push(AlbumConfig {
album_id,
name: album_name.clone(),
});
global_data
.cfg
.save(&global_data.args.cfg_path)
.await
.unwrap();
msg.reply(&ctx.http, format!("{} album added!", album_name))
.await?;
Ok(())
}
#[command]
#[only_in(guilds)]
#[max_args(1)]
async fn remove_album(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let album_name = args.parse::<String>()?;
let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap();
global_data
.cfg
.albums
.retain(|album| !album.name.eq_ignore_ascii_case(&album_name));
global_data
.cfg
.save(&global_data.args.cfg_path)
.await
.unwrap();
msg.reply(&ctx.http, format!("{} album removed!", album_name))
.await?;
Ok(())
}
#[command]
#[aliases("albums")]
#[only_in(guilds)]
async fn list_albums(ctx: &Context, msg: &Message, _args: Args) -> CommandResult {
let data = ctx.data.read().await;
let global_data = data.get::<GlobalData>().unwrap();
let album_names: Vec<String> = global_data
.cfg
.albums
.iter()
.map(|album| album.name.clone())
.collect();
if album_names.is_empty() {
msg.reply(&ctx.http, "There are no albums configured!")
.await?;
} else {
msg.reply(
&ctx.http,
format!("**Albums**:\n{}", album_names.join("\n")),
)
.await?;
}
Ok(())
}

102
src/discord/color.rs Normal file
View File

@ -0,0 +1,102 @@
use crate::{command, group};
use serenity::client::Context;
use serenity::framework::standard::{Args, CommandResult};
use serenity::model::channel::Message;
use serenity::utils::Colour;
#[group]
#[commands(set_color, remove_color)]
pub struct Color;
#[command]
#[example("#35BB1D")]
#[example("0x35BB1D")]
#[example("53 187 29")]
#[min_args(1)]
#[max_args(3)]
#[only_in(guilds)]
async fn set_color(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let color = if args.len() == 3 {
Colour::from_rgb(
args.parse::<u8>()?,
args.parse::<u8>()?,
args.parse::<u8>()?,
)
} else {
let color_str = args.rest();
if color_str.starts_with("0x") || color_str.starts_with('#') {
let color_str = color_str.replace("0x", "").replace('#', "");
let val = u32::from_str_radix(&color_str, 16)?;
Colour::new(val)
} else {
Colour::new(color_str.parse()?)
}
};
let guild = msg.guild(&ctx.cache).unwrap();
let (_, member) = guild
.members
.iter()
.find(|(id, _)| id == &&msg.author.id)
.unwrap();
let role = if let Some(role) = member
.roles(&ctx.cache)
.unwrap_or_default()
.iter()
.find(|r| r.name.contains("COwOlor"))
{
role.clone()
} else {
guild
.create_role(&ctx.http, |r| {
r.name(&format!("{} COwOlor", member.user.name))
})
.await?
};
guild
.edit_role(&ctx.http, role.id, |r| {
r.position(255).hoist(true).colour(color.0 as u64)
})
.await?;
let len = guild.roles.len() - 1;
guild
.edit_role_position(&ctx.http, role.id, len as u64)
.await?;
member.clone().add_role(&ctx.http, role).await?;
msg.reply(&ctx.http, "Color set!").await?;
Ok(())
}
#[command]
#[max_args(0)]
#[only_in(guilds)]
async fn remove_color(ctx: &Context, msg: &Message, _args: Args) -> CommandResult {
let guild = msg.guild(&ctx.cache).unwrap();
let (_, member) = guild
.members
.iter()
.find(|(id, _)| id == &&msg.author.id)
.unwrap();
if let Some(role) = member
.roles(&ctx.cache)
.unwrap_or_default()
.iter()
.find(|r| r.name.contains("COwOlor"))
{
role.clone().delete(&ctx.http).await?;
}
msg.reply(&ctx.http, "Color removed!").await?;
Ok(())
}

85
src/discord/mod.rs Normal file
View File

@ -0,0 +1,85 @@
pub mod album;
pub mod color;
use crate::error::Error;
use crate::{help, hook, imgur, GlobalData};
use rand::prelude::SliceRandom;
use serenity::client::Context;
use serenity::framework::standard::{
help_commands, Args, CommandGroup, CommandResult, HelpOptions,
};
use serenity::model::channel::Message;
use serenity::model::id::UserId;
use std::collections::HashSet;
#[hook]
pub async fn after(
_ctx: &Context,
_msg: &Message,
command_name: &str,
command_result: CommandResult,
) {
match command_result {
Ok(()) => println!("Processed command '{}'", command_name),
Err(why) => println!("Command '{}' returned error {:?}", command_name, why),
}
}
pub async fn parse_album(ctx: &Context, msg: &Message, album_name: &str) -> Result<(), Error> {
let data = ctx.data.read().await;
let global_data = data.get::<GlobalData>().unwrap();
let album = global_data
.cfg
.albums
.iter()
.find(|album| album.name.to_lowercase() == album_name);
if let Some(album) = album {
match imgur::get_album_images(&global_data.cfg.imgur_client_id, &album.album_id).await {
Ok(album) => {
let image = {
let mut rng = rand::thread_rng();
album.choose(&mut rng)
};
if let Some(image) = image {
msg.reply(&ctx.http, &image.link).await?;
} else {
msg.reply(&ctx.http, "Album empty, try and add some images.")
.await?;
}
}
Err(_) => {
msg.reply(&ctx.http, "Unable to get album, try again later.")
.await?;
}
}
};
Ok(())
}
#[hook]
pub async fn unrecognised_command_hook(
ctx: &Context,
msg: &Message,
unrecognised_command_name: &str,
) {
if let Err(e) = parse_album(ctx, msg, unrecognised_command_name).await {
println!("Error processing album command: {}", e)
}
}
#[help]
pub async fn my_help(
context: &Context,
msg: &Message,
args: Args,
help_options: &'static HelpOptions,
groups: &[&'static CommandGroup],
owners: HashSet<UserId>,
) -> CommandResult {
let _ = help_commands::with_embeds(context, msg, args, help_options, groups, owners).await;
Ok(())
}

37
src/error.rs Normal file
View File

@ -0,0 +1,37 @@
use crate::imgur::ImgurError;
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum Error {
ConfigError(config::ConfigError),
ImgurError(ImgurError),
SerenityError(serenity::Error),
}
impl From<config::ConfigError> for Error {
fn from(e: config::ConfigError) -> Self {
Self::ConfigError(e)
}
}
impl From<ImgurError> for Error {
fn from(e: ImgurError) -> Self {
Self::ImgurError(e)
}
}
impl From<serenity::Error> for Error {
fn from(err: serenity::Error) -> Self {
Self::SerenityError(err)
}
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Error::ConfigError(e) => write!(f, "Config error: {}", e),
Error::ImgurError(e) => write!(f, "Imgur error: {}", e),
Error::SerenityError(e) => write!(f, "Discord error: {}", e),
}
}
}

73
src/imgur/mod.rs Normal file
View File

@ -0,0 +1,73 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
#[derive(Debug)]
pub enum ImgurError {
ReqwestError(reqwest::Error),
ImgurRequestError(String),
}
impl From<reqwest::Error> for ImgurError {
fn from(e: reqwest::Error) -> Self {
Self::ReqwestError(e)
}
}
impl Display for ImgurError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let msg = match self {
ImgurError::ReqwestError(err) => format!("Reqwest error: {}", err),
ImgurError::ImgurRequestError(msg) => format!("Imgur request error: {}", msg),
};
write!(f, "{}", msg)
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AlbumData {
images: Option<Vec<Image>>,
error: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AlbumResponse {
data: AlbumData,
success: bool,
status: i32,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Image {
pub id: String,
pub title: Option<String>,
pub description: Option<String>,
#[serde(rename = "type")]
pub img_type: String,
pub animated: bool,
pub width: i32,
pub height: i32,
pub size: i32,
pub link: String,
}
pub async fn get_album_images(client_id: &str, album_hash: &str) -> Result<Vec<Image>, ImgurError> {
let client = Client::new();
let res = client
.get(format!("https://api.imgur.com/3/album/{}", album_hash))
.header("Authorization", format!("Client-ID {}", client_id))
.send()
.await?;
let album_response: AlbumResponse = res.json().await?;
if album_response.success {
Ok(album_response.data.images.unwrap())
} else {
Err(ImgurError::ImgurRequestError(
album_response.data.error.unwrap(),
))
}
}

45
src/main.rs Normal file
View File

@ -0,0 +1,45 @@
mod config;
mod discord;
mod error;
mod imgur;
use crate::config::{Args, BotConfig, GlobalData};
use crate::discord::unrecognised_command_hook;
use serenity::framework::standard::macros::{command, group, help, hook};
use serenity::framework::standard::StandardFramework;
use serenity::prelude::*;
use structopt::StructOpt;
#[tokio::main]
async fn main() {
let args: Args = Args::from_args();
let cfg = match BotConfig::new(&args.cfg_path) {
Ok(cfg) => cfg,
Err(err) => {
println!("Unable to open config: {}", err);
return;
}
};
let global_data = GlobalData { args, cfg };
let framework = StandardFramework::new()
.configure(|c| c.with_whitespace(true).prefix("!").ignore_bots(true))
.group(&discord::color::COLOR_GROUP)
.group(&discord::album::ALBUM_GROUP)
.unrecognised_command(unrecognised_command_hook)
.help(&discord::MY_HELP)
.after(discord::after);
let intents = GatewayIntents::all();
let mut client = Client::builder(&global_data.cfg.bot_token, intents)
.framework(framework)
.type_map_insert::<GlobalData>(global_data)
.await
.expect("Unable to create client.");
if let Err(err) = client.start().await {
println!("Client error: {:?}", err);
}
}