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