diff --git a/readme.md b/readme.md index 4af0b0c..6671b73 100644 --- a/readme.md +++ b/readme.md @@ -41,6 +41,14 @@ Run a bot with some custom listeners cargo run --bin simple_bot_listener ``` +## Bot with Example Custom Command +Run a bot with some custom listeners + +``` +cargo run --bin bot_cmd_example +``` + + # Example Code @@ -50,7 +58,7 @@ cargo run --bin simple_bot_listener Uses Env defined variables to create and run the bot ```rust -use forcebot_rs_v2::botcore::bot::Bot; +use forcebot_rs_v2::Bot; #[tokio::main] pub async fn main() { @@ -73,8 +81,9 @@ Example listener listens for a moderator badge and reply in chat ```rust use std::sync::Arc; -use forcebot_rs_v2::botcore::{bot::Bot, bot_objects::listener::asyncfn_box}; -use forcebot_rs_v2::botcore::bot_objects::listener::Listener; +use forcebot_rs_v2::Bot; +use forcebot_rs_v2::asyncfn_box; +use forcebot_rs_v2::Listener; use twitch_irc::message::ServerMessage; @@ -124,6 +133,49 @@ pub async fn main() { ``` +## Bot with Custom command + +```rust +use std::sync::Arc; + +use forcebot_rs_v2::Bot; +use forcebot_rs_v2::asyncfn_box; +use forcebot_rs_v2::Command; +use twitch_irc::message::ServerMessage; + + +#[tokio::main] +pub async fn main() { + + /* Create the bot using env */ + let mut bot = Bot::new(); + + /* 1. Create a new blank cmd */ + let mut cmd = Command::new("tester".to_string(),"".to_string()); + + /* 2. Define an async fn callback execution */ + async fn execbody(bot:Arc<Bot>,message:ServerMessage) -> Result<String,String> { + if let ServerMessage::Privmsg(msg) = message { + match bot.client.say_in_reply_to(&msg, String::from("cmd tested")).await { + Ok(_) => return Result::Ok("Success".to_string()) , + Err(_) => return Result::Err("Not Valid message type".to_string()) + } + } + Result::Err("Not Valid message type".to_string()) + } + + /* 3. Set and Store the execution body using `async_box()` */ + cmd.set_exec_fn(asyncfn_box(execbody)); + + /* 4. Load the cmd into the bot */ + bot.load_command(cmd); + + /* Run the bot */ + bot.run().await; + +} +``` + # Crate Rust Documentation Create Crate documentation diff --git a/src/bin/bot_cmd_example.rs b/src/bin/bot_cmd_example.rs new file mode 100644 index 0000000..fa3d6a6 --- /dev/null +++ b/src/bin/bot_cmd_example.rs @@ -0,0 +1,51 @@ +//! Bot with custom example commands that responds to caller if allowed +//! +//! Be sure the followig is defined in `.env` +//! - login_name +//! - access_token +//! - bot_channels +//! - prefix +//! - bot_admins +//! +//! Bot access tokens be generated here - +//! - Get a Bot Chat Token here - <https://twitchtokengenerator.com> +//! - More Info - <https://dev.twitch.tv/docs/authentication> + +use std::sync::Arc; + +use forcebot_rs_v2::Bot; +use forcebot_rs_v2::asyncfn_box; +use forcebot_rs_v2::Command; +use twitch_irc::message::ServerMessage; + + +#[tokio::main] +pub async fn main() { + + /* Create the bot using env */ + let mut bot = Bot::new(); + + /* 1. Create a new blank cmd */ + let mut cmd = Command::new("tester".to_string(),"".to_string()); + + /* 2. Define an async fn callback execution */ + async fn execbody(bot:Arc<Bot>,message:ServerMessage) -> Result<String,String> { + if let ServerMessage::Privmsg(msg) = message { + match bot.client.say_in_reply_to(&msg, String::from("cmd tested")).await { + Ok(_) => return Result::Ok("Success".to_string()) , + Err(_) => return Result::Err("Not Valid message type".to_string()) + } + } + Result::Err("Not Valid message type".to_string()) + } + + /* 3. Set and Store the execution body using `async_box()` */ + cmd.set_exec_fn(asyncfn_box(execbody)); + + /* 4. Load the cmd into the bot */ + bot.load_command(cmd); + + /* Run the bot */ + bot.run().await; + +} diff --git a/src/bin/simple_bot.rs b/src/bin/simple_bot.rs index 674a5d9..cbe682b 100644 --- a/src/bin/simple_bot.rs +++ b/src/bin/simple_bot.rs @@ -10,7 +10,7 @@ //! - Get a Bot Chat Token here - <https://twitchtokengenerator.com> //! - More Info - <https://dev.twitch.tv/docs/authentication> -use forcebot_rs_v2::botcore::bot::Bot; +use forcebot_rs_v2::Bot; #[tokio::main] pub async fn main() { diff --git a/src/bin/simple_bot_listener.rs b/src/bin/simple_bot_listener.rs index b2a9141..11a62e9 100644 --- a/src/bin/simple_bot_listener.rs +++ b/src/bin/simple_bot_listener.rs @@ -13,8 +13,9 @@ use std::sync::Arc; -use forcebot_rs_v2::botcore::{bot::Bot, bot_objects::listener::asyncfn_box}; -use forcebot_rs_v2::botcore::bot_objects::listener::Listener; +use forcebot_rs_v2::Bot; +use forcebot_rs_v2::asyncfn_box; +use forcebot_rs_v2::Listener; use twitch_irc::message::ServerMessage; diff --git a/src/botcore/bot.rs b/src/botcore/bot.rs index e21de83..4292dac 100644 --- a/src/botcore/bot.rs +++ b/src/botcore/bot.rs @@ -5,6 +5,8 @@ use twitch_irc::{login::StaticLoginCredentials, message::ServerMessage, SecureTC use dotenv::dotenv; use std::{env, sync::Arc}; +use crate::Command; + use super::bot_objects::listener::Listener; @@ -13,15 +15,19 @@ use super::bot_objects::listener::Listener; pub struct Bot { /// Prefix for commands - _prefix: char, + prefix: String, /// inbound chat msg stream incoming_msgs: Mutex<UnboundedReceiver<ServerMessage>>, /// outbound chat client msg stream pub client: TwitchIRCClient<SecureTCPTransport,StaticLoginCredentials>, /// joined channels botchannels: Vec<String>, + /// admin chatters + admins : Vec<String>, /// listeners listeners: Vec<Listener>, + /// commands + commands: Vec<Command>, } @@ -42,16 +48,17 @@ impl Bot let oauth_token = env::var("access_token").unwrap().to_owned(); let prefix = env::var("prefix") .unwrap() - .to_owned() - .chars() - .next() - .expect("ERROR : when defining prefix"); + .to_owned(); + // .chars() + // .next() + // .expect("ERROR : when defining prefix"); let mut botchannels = Vec::new(); for chnl in env::var("bot_channels").unwrap().split(',') { botchannels.push(chnl.to_owned()); } + Bot::new_from(bot_login_name, oauth_token, prefix, botchannels) @@ -61,7 +68,7 @@ impl Bot /// Creates a new `Bot` using bot information /// /// Bot joined channels will include channels from `.env` and `botchannels` argument - pub fn new_from(bot_login_name:String,oauth_token:String,prefix:char,botchannels:Vec<String>) -> Bot { + pub fn new_from(bot_login_name:String,oauth_token:String,prefix:String,botchannels:Vec<String>) -> Bot { dotenv().ok(); let bot_login_name = bot_login_name; @@ -81,12 +88,24 @@ impl Bot botchannels_all.push(chnl.to_owned()); } + + let mut admins = Vec::new(); + + if let Ok(value) = env::var("bot_admins") { + for admin in value.split(',') { + admins.push(String::from(admin)) + } + } + + Bot { - _prefix : prefix, + prefix, incoming_msgs : Mutex::new(incoming_messages), client, botchannels : botchannels_all, listeners : vec![], + commands : vec![], + admins, } } @@ -115,6 +134,14 @@ impl Bot let _ = listener.execute_fn(bot.clone(),message.clone()).await; } } + for cmd in &(*bot).commands { + + let a = cmd.clone(); + if a.command_triggered(bot.clone(),message.clone()) { + + let _ = cmd.execute_fn(bot.clone(),message.clone()).await; + } + } } drop(in_msgs_lock); }); @@ -126,5 +153,20 @@ impl Bot pub fn load_listener(&mut self,l : Listener) { self.listeners.push(l); } + + /// Loads a `Command` into the bot + pub fn load_command(&mut self,c : Command) { + self.commands.push(c); + } + + + + pub fn get_prefix(&self) -> String { + self.prefix.clone() + } + + pub fn get_admins(&self) -> Vec<String> { + self.admins.clone() + } } \ No newline at end of file diff --git a/src/botcore/bot_objects.rs b/src/botcore/bot_objects.rs index baefb0b..7bb8186 100644 --- a/src/botcore/bot_objects.rs +++ b/src/botcore/bot_objects.rs @@ -1 +1,23 @@ -pub mod listener; \ No newline at end of file +pub mod listener; +pub mod command; + + +use std::boxed::Box; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use twitch_irc::message::ServerMessage; + +use super::bot::Bot; + +pub type ExecBody = Box< + dyn Fn(Arc<Bot>,ServerMessage) -> Pin<Box<dyn Future<Output = Result<String,String>> + Send>> + Send + Sync, +>; + +pub fn asyncfn_box<T>(f: fn(Arc<Bot>,ServerMessage) -> T) -> ExecBody +where + T: Future<Output = Result<String,String>> + Send + 'static, +{ + Box::new(move |a,b| Box::pin(f(a,b))) +} \ No newline at end of file diff --git a/src/botcore/bot_objects/command.rs b/src/botcore/bot_objects/command.rs new file mode 100644 index 0000000..2239991 --- /dev/null +++ b/src/botcore/bot_objects/command.rs @@ -0,0 +1,157 @@ +use std::sync::Arc; + +use twitch_irc::message::ServerMessage; + +use crate::{asyncfn_box, botcore::bot::Bot}; + +use super::ExecBody; + +/// Bot `Command` that stores trigger condition callback and a execution functon +/// +/// A prefix character or phrase can be defined for the bot to evaluate a trigger condition +/// +/// A command or command phrase defines the phrase after the prefix phrase +/// +/// If no min badge role is provided, Broadcaster is defaulted. All commands require at least a vip role +/// +/// AdminOnly commands can only be ran by admin +/// +/// Use `asyncfn_box()` on custom async execution bodies +#[derive(Clone)] +pub struct Command +{ + command : String, + exec_fn : Arc<ExecBody>, + min_badge : String, + admin_only : bool, + prefix : String, + custom_cond_fn : fn(Arc<Bot>,ServerMessage) -> bool, +} + +impl Command +{ + + /// Creates a new empty `Command` using command `String` and prefix `String` + /// Pass an empty string prefix if the bot should use the bot default + /// + /// Call `set_trigger_cond_fn()` and `set_exec_fn()` to trigger & execution function callbacks + /// if a blank prefix is given, the bot will look for the bot prefix instead + /// + /// By default, the new command is admin_only + pub fn new(command:String,prefix:String) -> Command { + + async fn execbody(_:Arc<Bot>,_:ServerMessage) -> Result<String,String> + { Result::Ok("success".to_string()) } + + Command { + command , + prefix , + exec_fn : Arc::new(asyncfn_box(execbody)), + min_badge : "vip".to_string(), + admin_only : true, + custom_cond_fn : |_:Arc<Bot>,_:ServerMessage| true, + } + } + + /// set a trigger conditin callback that returns true if the listener shoud trigger + pub fn set_custom_cond_fn(&mut self,cond_fn: fn(Arc<Bot>,ServerMessage) -> bool) { + self.custom_cond_fn = cond_fn; + } + + /// sets the execution body of the listener for when it triggers + /// + /// Use`asyncfn_box()` on the async fn when storing + /// + /// + pub fn set_exec_fn(&mut self,exec_fn:ExecBody ) { + self.exec_fn = Arc::new(exec_fn); + } + + /// checks if the trigger condition is met + /// specifically if the message is a valid command and min badge roles provided + /// + pub fn command_triggered(&self,bot:Arc<Bot>,msg:ServerMessage) -> bool { + + + fn cmd_called(cmd:&Command,bot:Arc<Bot>,message:ServerMessage) -> bool { + if let ServerMessage::Privmsg(msg) = message { + // dbg!(msg.clone()); + let mut prefixed_cmd = "".to_string(); + if cmd.prefix == "" { + prefixed_cmd.push_str(&bot.get_prefix()); + } else { + prefixed_cmd.push_str(&cmd.prefix); + } + prefixed_cmd.push_str(&cmd.command); + return msg.message_text.starts_with(prefixed_cmd.as_str()) + } else { false } + } + + + fn caller_badge_ok(cmd:&Command,_bot:Arc<Bot>,message:ServerMessage) -> bool { + + if let ServerMessage::Privmsg(msg) = message { + // dbg!(msg.clone()) + for badge in msg.badges { + + match cmd.min_badge.as_str() { + "broadcaster" => { + if badge.name == cmd.min_badge { return true } + else { return false } + }, + "moderator" => { + match badge.name.as_str() { + "moderator" | "broadcaster" => return true, + _ => (), + } + }, + "vip" => { + match badge.name.as_str() { + "vip" | "moderator" | "broadcaster" => return true, + _ => (), + } + }, + _ => return false, + } + } + + return false; + } else { + return false; + } + + } + + + /// determines if the command caller can run the command + /// based on admin_only flag + /// + /// callers who are admins can run admin_only commands + /// callers can run non-admin_only commands + fn admin_only_ok(cmd:&Command,bot:Arc<Bot>,message:ServerMessage) -> bool { + + if let ServerMessage::Privmsg(msg) = message { + if cmd.admin_only && bot.get_admins().contains(&msg.sender.login) { + return true; + } else { + return false; + } + } else { false } + } + + fn custom_cond_ok(cmd:&Command,bot:Arc<Bot>,message:ServerMessage) -> bool { + (cmd.custom_cond_fn)(bot,message) + } + + cmd_called(self, bot.clone(), msg.clone()) && + caller_badge_ok(self, bot.clone(), msg.clone()) && + admin_only_ok(self, bot.clone(), msg.clone()) && + custom_cond_ok(self, bot, msg) + + } + + /// executes the listeners executon body + pub async fn execute_fn(&self,bot:Arc<Bot>,msg:ServerMessage) -> Result<String, String> { + (self.exec_fn)(bot,msg).await + } +} diff --git a/src/botcore/bot_objects/listener.rs b/src/botcore/bot_objects/listener.rs index 0f953c4..2bebb8e 100644 --- a/src/botcore/bot_objects/listener.rs +++ b/src/botcore/bot_objects/listener.rs @@ -2,7 +2,9 @@ use std::sync::Arc; use twitch_irc::message::ServerMessage; -use crate::botcore::bot::Bot; +use crate::{asyncfn_box, Bot}; + +use super::ExecBody; /// Bot `Listener`` that stores trigger condition callback and a execution functon /// @@ -10,7 +12,9 @@ use crate::botcore::bot::Bot; #[derive(Clone)] pub struct Listener { + /// trigger condition trigger_cond_fn : fn(Arc<Bot>,ServerMessage) -> bool, + /// execution body exec_fn : Arc<ExecBody>, } @@ -66,18 +70,3 @@ impl Listener } } - -use std::boxed::Box; -use std::future::Future; -use std::pin::Pin; - -pub type ExecBody = Box< - dyn Fn(Arc<Bot>,ServerMessage) -> Pin<Box<dyn Future<Output = Result<String,String>> + Send>> + Send + Sync, ->; - -pub fn asyncfn_box<T>(f: fn(Arc<Bot>,ServerMessage) -> T) -> ExecBody -where - T: Future<Output = Result<String,String>> + Send + 'static, -{ - Box::new(move |a,b| Box::pin(f(a,b))) -} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index ce1adca..4b1a70d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,10 @@ -//! `forcebot-rs-v2` : Twitch chat bot written in rust +//! `forcebot-rs-v2` : Twitch chat bot written in rust. +//! //! Customize by adding additional bot objects //! //! # Example Simple Bot //! ``` -//! use forcebot_rs_v2::botcore::bot::Bot; +//! use forcebot_rs_v2::Bot; //! //! #[tokio::main] //!pub async fn main() { @@ -27,8 +28,9 @@ //! ``` //! use std::sync::Arc; //! -//! use forcebot_rs_v2::botcore::{bot::Bot, bot_objects::listener::asyncfn_box}; -//! use forcebot_rs_v2::botcore::bot_objects::listener::Listener; +//! use forcebot_rs_v2::Bot; +//! use forcebot_rs_v2::asyncfn_box; +//! use forcebot_rs_v2::Listener; //! use twitch_irc::message::ServerMessage; //! //! @@ -77,6 +79,50 @@ //! } //! ``` //! +//! # Example Bot with Custom Command +//! ``` +//! use std::sync::Arc; //! +//! use forcebot_rs_v2::Bot; +//! use forcebot_rs_v2::asyncfn_box; +//! use forcebot_rs_v2::Command; +//! use twitch_irc::message::ServerMessage; +//! +//! +//! #[tokio::main] +//! pub async fn main() { +//! +//! /* Create the bot using env */ +//! let mut bot = Bot::new(); +//! +//! /* 1. Create a new blank cmd */ +//! let mut cmd = Command::new("tester".to_string(),"".to_string()); +//! +//! /* 2. Define an async fn callback execution */ +//! async fn execbody(bot:Arc<Bot>,message:ServerMessage) -> Result<String,String> { +//! if let ServerMessage::Privmsg(msg) = message { +//! match bot.client.say_in_reply_to(&msg, String::from("cmd tested")).await { +//! Ok(_) => return Result::Ok("Success".to_string()) , +//! Err(_) => return Result::Err("Not Valid message type".to_string()) +//! } +//! } +//! Result::Err("Not Valid message type".to_string()) +//! } +//! +//! /* 3. Set and Store the execution body using `async_box()` */ +//! cmd.set_exec_fn(asyncfn_box(execbody)); +//! +//! /* 4. Load the cmd into the bot */ +//! bot.load_command(cmd); +//! +//! /* Run the bot */ +//! bot.run().await; +//! +//! } +//! ``` -pub mod botcore; \ No newline at end of file +pub mod botcore; +pub use crate::botcore::bot::Bot; +pub use crate::botcore::bot_objects::asyncfn_box; +pub use crate::botcore::bot_objects::listener::Listener; +pub use crate::botcore::bot_objects::command::Command; \ No newline at end of file