Initial commit
+ Working color and album commands + Clippy + fmt
This commit is contained in:
commit
ae8b513cdf
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/target
|
||||
/.idea
|
||||
config.toml
|
||||
2064
Cargo.lock
generated
Normal file
2064
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
Cargo.toml
Normal file
22
Cargo.toml
Normal 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
54
src/config.rs
Normal 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
95
src/discord/album.rs
Normal 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
102
src/discord/color.rs
Normal 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
85
src/discord/mod.rs
Normal 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
37
src/error.rs
Normal 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
73
src/imgur/mod.rs
Normal 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
45
src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user