From 6cdc465a2e2180155a522874f3e5f3a817e9b438 Mon Sep 17 00:00:00 2001 From: projectmoon Date: Thu, 15 Oct 2020 16:52:08 +0000 Subject: [PATCH] Add database and storage of user variables. This commit introduces the Sled embedded key-value store for keeping track of user variables on a per-room basis. Extensive changes were made to the command module to separate concerns and also pass the database "connection" down the line. - A new "Context" object was created to hold information and state needed for command execution (namely the database). - Database is very simple for now, storing only user variables. Refactoring later for storing more complicated types. - State actor moved into Actors struct, in preparation for either more actors, or ripping the whole thing out entirely. - Other modules are also more properly separated, notably the config module is entirely self-contained. --- .gitignore | 1 + Cargo.lock | 93 +++++++++++++++++++ Cargo.toml | 5 ++ Dockerfile | 2 + README.md | 52 +++++++++-- src/actors.rs | 25 ++++++ src/{ => actors}/state.rs | 0 src/bin/dicebot-cmd.rs | 21 +++-- src/bin/dicebot.rs | 66 ++------------ src/bot.rs | 65 +++++++------- src/commands.rs | 183 +++++++++++++++++++++++++++----------- src/commands/parser.rs | 22 ++++- src/config.rs | 183 ++++++++++++++++++++++++++++++++++++++ src/context.rs | 27 ++++++ src/db.rs | 82 +++++++++++++++++ src/error.rs | 41 ++++++++- src/lib.rs | 5 +- src/variables.rs | 43 +++++++++ 18 files changed, 749 insertions(+), 167 deletions(-) create mode 100644 src/actors.rs rename src/{ => actors}/state.rs (100%) create mode 100644 src/context.rs create mode 100644 src/db.rs create mode 100644 src/variables.rs diff --git a/.gitignore b/.gitignore index 05b7944..cbd44e8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ todo.org cache *.tar *.tar.gz +test-db/ diff --git a/Cargo.lock b/Cargo.lock index 086d252..979f698 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,9 +283,11 @@ dependencies = [ "actix", "actix-rt", "async-trait", + "byteorder", "combine", "dirs", "env_logger", + "futures 0.3.6", "indoc", "itertools", "log", @@ -296,10 +298,12 @@ dependencies = [ "nom", "rand", "serde", + "sled", "thiserror", "tokio", "toml", "url", + "zerocopy", ] [[package]] @@ -391,6 +395,15 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" +[[package]] +name = "crc32fast" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba125de2af0df55319f41944744ad91c71113bf74a4646efff39afe1f6842db1" +dependencies = [ + "cfg-if 0.1.10", +] + [[package]] name = "crossbeam-channel" version = "0.4.4" @@ -401,6 +414,21 @@ dependencies = [ "maybe-uninit", ] +[[package]] +name = "crossbeam-epoch" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "058ed274caafc1f60c4997b5fc07bf7dc7cca454af7c6e81edffe5f33f70dace" +dependencies = [ + "autocfg", + "cfg-if 0.1.10", + "crossbeam-utils", + "lazy_static", + "maybe-uninit", + "memoffset", + "scopeguard", +] + [[package]] name = "crossbeam-queue" version = "0.2.3" @@ -548,6 +576,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi 0.3.9", +] + [[package]] name = "fuchsia-zircon" version = "0.3.3" @@ -692,6 +730,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "getrandom" version = "0.1.15" @@ -1197,6 +1244,15 @@ version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400" +[[package]] +name = "memoffset" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "043175f069eda7b85febe4a74abbaeff828d9f8b448515d3151a14a3542811aa" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.16" @@ -2007,6 +2063,22 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8" +[[package]] +name = "sled" +version = "0.34.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f72c064e63fbca3138ad07f3588c58093f1684f3a99f60dcfa6d46b87e60fde7" +dependencies = [ + "crc32fast", + "crossbeam-epoch", + "crossbeam-utils", + "fs2", + "fxhash", + "libc", + "log", + "parking_lot", +] + [[package]] name = "smallvec" version = "1.4.2" @@ -2656,6 +2728,27 @@ dependencies = [ "winapi-build", ] +[[package]] +name = "zerocopy" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6580539ad917b7c026220c4b3f2c08d52ce54d6ce0dc491e66002e35388fab46" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d498dbd1fd7beb83c86709ae1c33ca50942889473473d287d56ce4770a18edfb" +dependencies = [ + "proc-macro2", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index aa826fe..f7a96cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,11 @@ indoc = "1.0" actix = "0.10" actix-rt = "1.1" combine = "4.3" +sled = "0.34" +zerocopy = "0.3" +byteorder = "1.3" +futures = "0.3" + # The versioning of the matrix SDK follows its Cargo.toml. The SDK and # macros are on master, but it imports the common and base from 0.1.0. diff --git a/Dockerfile b/Dockerfile index b53e4e7..9e2ace7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ # Builder image with development dependencies. FROM bougyman/voidlinux:glibc as builder +RUN xbps-install -Syu RUN xbps-install -Sy base-devel rust cargo cmake wget gnupg RUN xbps-install -Sy libressl-devel olm-devel libstdc++-devel @@ -36,4 +37,5 @@ COPY --from=builder \ /usr/local/bin/ ENV XDG_CACHE_HOME "/cache" +ENV DATABASE_PATH "/cache/bot-db" ENTRYPOINT [ "/usr/local/bin/tini", "-v", "--", "/usr/local/bin/dicebot", "/config/dicebot-config.toml" ] diff --git a/README.md b/README.md index a24b656..748725e 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ either the Storytelling System or more traditional RPG dice rolls. The bot supports a `!help` command for basic help information about its capabilities. +### Basic Dice Rolling The commands `!roll` and `!r` can handle arbitrary dice roll expressions. @@ -84,26 +85,49 @@ expressions. !r 3d12 - 5d2 + 3 - 7d3 + 20d20 ``` +This system does not yet have the capability to handle things like D&D +5e advantage or disadvantage. + +### Storytelling System + The commands `!pool` (or `!rp`) and `!chance` are for the Storytelling System, and they use a specific syntax to support the dice system. The simplest version of the command is `!pool ` to roll a pool of the given size using the most common type of roll. -The type of roll can be controlled by adding `n`, `e`, or `r` after +The type of roll can be controlled by adding `n`, `e`, or `r` before the number, for 9-again, 8-again, and rote quality rolls. The number of successes required for an exceptional success can be controlled by `s`, e.g. `s3` to only need 3 successes for an exceptional -success. +success. All modifiers should come before the number, with a `:` +colon. Examples: ``` !pool 8 //regular pool of 8 dice -!pool 8n //roll 8 dice, 9-again -!pool 8ns3 //roll 8 dice, 9-again with only 3 successes for exceptional -!pool 5rs2 //5 dice, rote quality, 2 successes for exceptional +!pool n:8 //roll 8 dice, 9-again +!pool ns3:8 //roll 8 dice, 9-again with only 3 successes for exceptional +!pool rs2:5 //5 dice, rote quality, 2 successes for exceptional ``` +### User Variables + +Users can store variables for use with the Storytelling dice pool +system. Variables are stored on a per-room, per-user basis in the +database (currently located in the cache directory if using the Docker +image). + +Examples: + +``` +!set myvar 5 //stores 5 for this room under the name "myvar" +!get myvar //will print 5 +``` + +Variables can be referenced in dice pool rolling expressions, for +example `!pool myvar` or `!pool myvar+3`. + ## Running the Bot The easiest way to run the bot is to use the [official Docker @@ -123,13 +147,14 @@ ghcr.io/projectmoon/chronicle-dicebot:$VERSION ``` The Docker image requires two volume mounts: the location of the -[config file][config-file], which should be mounted at `/config/dicebot-config.toml`, -and a cache directory to store client state after initial sync. That -should be mounted at `/cache/`in the container. +[config file][config-file], which should be mounted at +`/config/dicebot-config.toml`, and a cache directory to store the +database and client state after initial sync. That should be mounted +at `/cache/`in the container. ### Configuration File -The configuration file is a TOML file with two sections. +The configuration file is a TOML file with three sections. ```toml [matrix] @@ -137,6 +162,9 @@ home_server = 'https://example.com' username = 'thisismyusername' password = 'thisismypassword' +[datbase] +path = '/path/to/database/directory/' + [bot] oldest_message_age = 300 ``` @@ -151,6 +179,12 @@ bot's matrix account. - `username`: Bot account username. - `password`: Bot account password. +The `[database]` section contains information for connecting to the +embedded database. Note: **you do not need this** if you are using the +Docker image. + - `path`: Path on the filesystem to use as the database storage + directory. + The `[bot]` section has settings for controlling how the bot operates. This section is optional and the settings will fall back to their default values if the section or setting is not present. diff --git a/src/actors.rs b/src/actors.rs new file mode 100644 index 0000000..a32895c --- /dev/null +++ b/src/actors.rs @@ -0,0 +1,25 @@ +use crate::config::Config; +use crate::db::Database; +use actix::prelude::*; +use state::DiceBotState; +use std::sync::Arc; + +pub mod state; + +pub struct Actors { + global_state: Addr, +} + +impl Actors { + pub fn new(config: &Arc, _db: &Database) -> Actors { + let global_state = DiceBotState::new(&config); + + Actors { + global_state: global_state.start(), + } + } + + pub fn global_state(&self) -> Addr { + self.global_state.clone() + } +} diff --git a/src/state.rs b/src/actors/state.rs similarity index 100% rename from src/state.rs rename to src/actors/state.rs diff --git a/src/bin/dicebot-cmd.rs b/src/bin/dicebot-cmd.rs index 42dddb3..6ba2697 100644 --- a/src/bin/dicebot-cmd.rs +++ b/src/bin/dicebot-cmd.rs @@ -1,12 +1,17 @@ -use chronicle_dicebot::commands::parse_command; +use chronicle_dicebot::commands::Command; +use chronicle_dicebot::context::Context; +use chronicle_dicebot::db::Database; +use chronicle_dicebot::error::BotError; -fn main() -> Result<(), String> { - let command = std::env::args().skip(1).collect::>().join(" "); - let command = match parse_command(&command) { - Ok(Some(command)) => command, - Ok(None) => return Err("Command not recognized".into()), - Err(e) => return Err(format!("Error parsing command: {}", e)), +fn main() -> Result<(), BotError> { + let db = Database::new(&sled::open("test-db")?); + let input = std::env::args().skip(1).collect::>().join(" "); + let command = match Command::parse(&input) { + Ok(command) => command, + Err(e) => return Err(e), }; - println!("{}", command.execute().plain()); + + let context = Context::new(&db, "roomid", "localuser", &input); + println!("{}", command.execute(&context).plain()); Ok(()) } diff --git a/src/bin/dicebot.rs b/src/bin/dicebot.rs index 73fb18d..22fa0ee 100644 --- a/src/bin/dicebot.rs +++ b/src/bin/dicebot.rs @@ -1,51 +1,36 @@ //Needed for nested Result handling from tokio. Probably can go away after 1.47.0. #![type_length_limit = "7605144"] use actix::prelude::*; +use chronicle_dicebot::actors::Actors; use chronicle_dicebot::bot::DiceBot; use chronicle_dicebot::config::*; +use chronicle_dicebot::db::Database; use chronicle_dicebot::error::BotError; -use chronicle_dicebot::state::DiceBotState; use env_logger::Env; use log::error; -use std::fs; -use std::path::PathBuf; use std::sync::Arc; -fn read_config>(config_path: P) -> Result { - let config_path = config_path.into(); - let config = { - let contents = fs::read_to_string(&config_path)?; - deserialize_config(&contents)? - }; - - Ok(config) -} - -fn deserialize_config(contents: &str) -> Result { - let config = toml::from_str(&contents)?; - Ok(config) -} - #[actix_rt::main] async fn main() { + env_logger::from_env(Env::default().default_filter_or("chronicle_dicebot=info,dicebot=info")) + .init(); match run().await { Ok(_) => (), - Err(e) => error!("Error: {:?}", e), + Err(e) => error!("Error: {}", e), }; } async fn run() -> Result<(), BotError> { - env_logger::from_env(Env::default().default_filter_or("chronicle_dicebot=info")).init(); - let config_path = std::env::args() .skip(1) .next() .expect("Need a config as an argument"); let cfg = Arc::new(read_config(config_path)?); - let bot_state = DiceBotState::new(&cfg).start(); + let db = Database::new(&sled::open(cfg.database_path())?); + let actors = Actors::new(&cfg, &db); - match DiceBot::new(&cfg, bot_state) { + match DiceBot::new(&cfg, actors, &db) { Ok(bot) => bot.run().await?, Err(e) => println!("Error connecting: {:?}", e), }; @@ -53,38 +38,3 @@ async fn run() -> Result<(), BotError> { System::current().stop(); Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use indoc::indoc; - - #[test] - fn deserialize_config_without_bot_section_test() { - let contents = indoc! {" - [matrix] - home_server = 'https://matrix.example.com' - username = 'username' - password = 'password' - "}; - - let cfg: Result<_, _> = deserialize_config(contents); - assert_eq!(true, cfg.is_ok()); - } - - #[test] - fn deserialize_config_without_oldest_message_setting_test() { - let contents = indoc! {" - [matrix] - home_server = 'https://matrix.example.com' - username = 'username' - password = 'password' - - [bot] - not_a_real_setting = 2 - "}; - - let cfg: Result<_, _> = deserialize_config(contents); - assert_eq!(true, cfg.is_ok()); - } -} diff --git a/src/bot.rs b/src/bot.rs index 961250f..7cc38a7 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,8 +1,10 @@ -use crate::commands::parse_command; +use crate::actors::state::LogSkippedOldMessages; +use crate::actors::Actors; +use crate::commands::execute_command; use crate::config::*; +use crate::context::Context; +use crate::db::Database; use crate::error::BotError; -use crate::state::{DiceBotState, LogSkippedOldMessages}; -use actix::Addr; use dirs; use log::{debug, error, info, trace, warn}; use matrix_sdk::{ @@ -31,9 +33,11 @@ pub struct DiceBot { /// The matrix client. client: Client, - /// Current state of the dice bot. Actor ref to the core state - /// actor. - state: Addr, + /// Actors used by the dice bot to manage internal state. + actors: Actors, + + /// Active database layer + db: Database, } fn cache_dir() -> Result { @@ -57,11 +61,12 @@ impl DiceBot { /// actor. This function returns a Result because it is possible /// for client creation to fail for some reason (e.g. invalid /// homeserver URL). - pub fn new(config: &Arc, state_actor: Addr) -> Result { + pub fn new(config: &Arc, actors: Actors, db: &Database) -> Result { Ok(DiceBot { client: create_client(&config)?, config: config.clone(), - state: state_actor, + actors: actors, + db: db.clone(), }) } @@ -135,13 +140,13 @@ fn check_message_age( } } -async fn should_process( +async fn should_process<'a>( bot: &DiceBot, event: &SyncMessageEvent, ) -> Result<(String, String), BotError> { //Ignore messages that are older than configured duration. if !check_message_age(event, bot.config.oldest_message_age()) { - let res = bot.state.send(LogSkippedOldMessages).await; + let res = bot.actors.global_state().send(LogSkippedOldMessages).await; if let Err(e) = res { error!("Actix error: {:?}", e); @@ -209,37 +214,29 @@ impl EventEmitter for DiceBot { return; }; - let (plain, html) = match parse_command(&msg_body) { - Ok(Some(command)) => { - let command = command.execute(); - (command.plain().into(), command.html().into()) - } - Ok(None) => return, //Ignore non-commands. - Err(e) => { - let message = format!("Error parsing command: {}", e); - let html_message = format!("

{}

", message); - (message, html_message) - } - }; - - let plain = format!("{}\n{}", sender_username, plain); - let html = format!("

{}

\n{}", sender_username, html); - let content = AnyMessageEventContent::RoomMessage(MessageEventContent::Notice( - NoticeMessageEventContent::html(plain, html), - )); - //we clone here to hold the lock for as little time as possible. let (room_name, room_id) = { let real_room = room.read().await; (real_room.display_name().clone(), real_room.room_id.clone()) }; - let result = self.client.room_send(&room_id, content, None).await; - if let Err(e) = result { - error!("Error sending message: {}", e.to_string()); - }; + let ctx = Context::new(&self.db, &room_id.as_str(), &sender_username, &msg_body); - info!("[{}] {} executed: {}", room_name, sender_username, msg_body); + if let Some(cmd_result) = execute_command(&ctx) { + let response = AnyMessageEventContent::RoomMessage(MessageEventContent::Notice( + NoticeMessageEventContent::html(cmd_result.plain, cmd_result.html), + )); + + let result = self.client.room_send(&room_id, response, None).await; + if let Err(e) = result { + error!("Error sending message: {}", e.to_string()); + }; + + info!( + "[{}] {} executed: {}", + room_name, ctx.username, ctx.message_body + ); + } } } } diff --git a/src/commands.rs b/src/commands.rs index 7153c82..48eaa0e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,6 +1,7 @@ use crate::cofd::dice::DicePool; +use crate::context::Context; use crate::dice::ElementExpression; -use crate::error::BotError; +use crate::error::{BotError, CommandError}; use crate::help::HelpTopic; use crate::roll::Roll; @@ -22,7 +23,7 @@ impl Execution { } pub trait Command { - fn execute(&self) -> Execution; + fn execute(&self, ctx: &Context) -> Execution; fn name(&self) -> &'static str; } @@ -33,7 +34,7 @@ impl Command for RollCommand { "roll regular dice" } - fn execute(&self) -> Execution { + fn execute(&self, _ctx: &Context) -> Execution { let roll = self.0.roll(); let plain = format!("Dice: {}\nResult: {}", self.0, roll); let html = format!( @@ -51,7 +52,7 @@ impl Command for PoolRollCommand { "roll dice pool" } - fn execute(&self) -> Execution { + fn execute(&self, _ctx: &Context) -> Execution { let roll_result = self.0.roll(); let (plain, html) = match roll_result { @@ -81,7 +82,7 @@ impl Command for HelpCommand { "help information" } - fn execute(&self) -> Execution { + fn execute(&self, _ctx: &Context) -> Execution { let help = match &self.0 { Some(topic) => topic.message(), _ => "There is no help for this topic", @@ -93,24 +94,116 @@ impl Command for HelpCommand { } } -/// Parse a command string into a dynamic command execution trait -/// object. Returns an error if a command was recognized but not -/// parsed correctly. Returns Ok(None) if no command was recognized. -pub fn parse_command(s: &str) -> Result>, BotError> { - // match parser::parse_command(s) { - // Ok(Some(command)) => match &command { - // //Any command, or text transformed into non-command is - // //sent upwards. - // ("", Some(_)) | (_, None) => Ok(command), +pub struct GetVariableCommand(String); - // //TODO replcae with nom all_consuming? - // //Any unconsumed input (whitespace should already be - // // stripped) is considered a parsing error. - // _ => Err(format!("{}: malformed expression", s)), - // }, - // Err(err) => Err(err), - // } - parser::parse_command(s) +impl Command for GetVariableCommand { + fn name(&self) -> &'static str { + "retrieve variable value" + } + + fn execute(&self, ctx: &Context) -> Execution { + let name = &self.0; + let value = match ctx.db.get_user_variable(ctx.room_id, ctx.username, name) { + Ok(Some(num)) => format!("{} = {}", name, num), + Ok(None) => format!("{} is not set", name), + Err(e) => format!("error getting {}: {}", name, e), + }; + + let plain = format!("Variable: {}", value); + let html = format!("

Variable: {}", value); + Execution { plain, html } + } +} + +pub struct SetVariableCommand(String, i32); + +impl Command for SetVariableCommand { + fn name(&self) -> &'static str { + "set variable value" + } + + fn execute(&self, ctx: &Context) -> Execution { + let name = &self.0; + let value = self.1; + let value = match ctx + .db + .set_user_variable(ctx.room_id, ctx.username, name, value) + { + Ok(_) => format!("{} = {}", name, value), + Err(e) => format!("error setting {}: {}", name, e), + }; + + let plain = format!("Set Variable: {}", value); + let html = format!("

Set Variable: {}", value); + Execution { plain, html } + } +} + +pub struct DeleteVariableCommand(String); + +impl Command for DeleteVariableCommand { + fn name(&self) -> &'static str { + "delete variable" + } + + fn execute(&self, ctx: &Context) -> Execution { + let name = &self.0; + let value = match ctx.db.delete_user_variable(ctx.room_id, ctx.username, name) { + Ok(()) => format!("{} now unset", name), + Err(e) => format!("error deleting {}: {}", name, e), + }; + + let plain = format!("Remove Variable: {}", value); + let html = format!("

Remove Variable: {}", value); + Execution { plain, html } + } +} + +impl dyn Command { + /// Parse a command string into a dynamic command execution trait + /// object. Returns an error if a command was recognized but not + /// parsed correctly. Returns Ok(None) if no command was recognized. + pub fn parse(s: &str) -> Result, BotError> { + match parser::parse_command(s) { + Ok(Some(command)) => Ok(command), + Ok(None) => Err(BotError::CommandError(CommandError::IgnoredCommand)), + Err(e) => Err(e), + } + } +} + +pub struct CommandResult { + pub plain: String, + pub html: String, +} + +/// Attempt to execute a command, and return the content that should +/// go back to Matrix, if the command was executed (successfully or +/// not). If a command is determined to be ignored, this function will +/// return None, signifying that we should not send a response. +pub fn execute_command<'a>(ctx: &'a Context) -> Option { + let res = Command::parse(ctx.message_body).map(|cmd| { + let execution = cmd.execute(ctx); + (execution.plain().into(), execution.html().into()) + }); + + let (plain, html) = match res { + Ok(plain_and_html) => plain_and_html, + Err(BotError::CommandError(CommandError::IgnoredCommand)) => return None, + Err(e) => { + let message = format!("Error parsing command: {}", e); + let html_message = format!("

{}

", message); + (message, html_message) + } + }; + + let plain = format!("{}\n{}", ctx.username, plain); + let html = format!("

{}

\n{}", ctx.username, html); + + Some(CommandResult { + plain: plain, + html: html, + }) } #[cfg(test)] @@ -119,57 +212,39 @@ mod tests { #[test] fn chance_die_is_not_malformed() { - assert!(parse_command("!chance").is_ok()); + assert!(Command::parse("!chance").is_ok()); } #[test] fn roll_malformed_expression_test() { - assert!(parse_command("!roll 1d20asdlfkj").is_err()); - assert!(parse_command("!roll 1d20asdlfkj ").is_err()); + assert!(Command::parse("!roll 1d20asdlfkj").is_err()); + assert!(Command::parse("!roll 1d20asdlfkj ").is_err()); } #[test] fn roll_dice_pool_malformed_expression_test() { - assert!(parse_command("!pool 8abc").is_err()); - assert!(parse_command("!pool 8abc ").is_err()); + assert!(Command::parse("!pool 8abc").is_err()); + assert!(Command::parse("!pool 8abc ").is_err()); } #[test] fn pool_whitespace_test() { - assert!(parse_command("!pool ns3:8 ") - .map(|p| p.is_some()) - .expect("was error")); - assert!(parse_command(" !pool ns3:8") - .map(|p| p.is_some()) - .expect("was error")); - assert!(parse_command(" !pool ns3:8 ") - .map(|p| p.is_some()) - .expect("was error")); + Command::parse("!pool ns3:8 ").expect("was error"); + Command::parse(" !pool ns3:8").expect("was error"); + Command::parse(" !pool ns3:8 ").expect("was error"); } #[test] fn help_whitespace_test() { - assert!(parse_command("!help stuff ") - .map(|p| p.is_some()) - .expect("was error")); - assert!(parse_command(" !help stuff") - .map(|p| p.is_some()) - .expect("was error")); - assert!(parse_command(" !help stuff ") - .map(|p| p.is_some()) - .expect("was error")); + Command::parse("!help stuff ").expect("was error"); + Command::parse(" !help stuff").expect("was error"); + Command::parse(" !help stuff ").expect("was error"); } #[test] fn roll_whitespace_test() { - assert!(parse_command("!roll 1d4 + 5d6 -3 ") - .map(|p| p.is_some()) - .expect("was error")); - assert!(parse_command("!roll 1d4 + 5d6 -3 ") - .map(|p| p.is_some()) - .expect("was error")); - assert!(parse_command(" !roll 1d4 + 5d6 -3 ") - .map(|p| p.is_some()) - .expect("was error")); + Command::parse("!roll 1d4 + 5d6 -3 ").expect("was error"); + Command::parse("!roll 1d4 + 5d6 -3 ").expect("was error"); + Command::parse(" !roll 1d4 + 5d6 -3 ").expect("was error"); } } diff --git a/src/commands/parser.rs b/src/commands/parser.rs index fb01daa..c339f00 100644 --- a/src/commands/parser.rs +++ b/src/commands/parser.rs @@ -1,8 +1,12 @@ use crate::cofd::parser::{create_chance_die, parse_dice_pool}; -use crate::commands::{Command, HelpCommand, PoolRollCommand, RollCommand}; +use crate::commands::{ + Command, DeleteVariableCommand, GetVariableCommand, HelpCommand, PoolRollCommand, RollCommand, + SetVariableCommand, +}; use crate::dice::parser::parse_element_expression; use crate::error::BotError; use crate::help::parse_help_topic; +use crate::variables::parse_set_variable; use combine::parser::char::{char, letter, space}; use combine::{any, many1, optional, Parser}; use nom::Err as NomErr; @@ -20,6 +24,19 @@ fn parse_roll(input: &str) -> Result, BotError> { } } +fn parse_get_variable_command(input: &str) -> Result, BotError> { + Ok(Box::new(GetVariableCommand(input.to_owned()))) +} + +fn parse_set_variable_command(input: &str) -> Result, BotError> { + let (variable_name, value) = parse_set_variable(input)?; + Ok(Box::new(SetVariableCommand(variable_name, value))) +} + +fn parse_delete_variable_command(input: &str) -> Result, BotError> { + Ok(Box::new(DeleteVariableCommand(input.to_owned()))) +} + fn parse_pool_roll(input: &str) -> Result, BotError> { let pool = parse_dice_pool(input)?; Ok(Box::new(PoolRollCommand(pool))) @@ -72,6 +89,9 @@ fn split_command(input: &str) -> Result<(String, String), BotError> { pub fn parse_command(input: &str) -> Result>, BotError> { match split_command(input) { Ok((cmd, cmd_input)) => match cmd.as_ref() { + "get" => parse_get_variable_command(&cmd_input).map(|command| Some(command)), + "set" => parse_set_variable_command(&cmd_input).map(|command| Some(command)), + "del" => parse_delete_variable_command(&cmd_input).map(|command| Some(command)), "r" | "roll" => parse_roll(&cmd_input).map(|command| Some(command)), "rp" | "pool" => parse_pool_roll(&cmd_input).map(|command| Some(command)), "chance" => chance_die().map(|command| Some(command)), diff --git a/src/config.rs b/src/config.rs index 58d0501..e3e58e5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,23 @@ +use crate::error::ConfigError; use serde::{self, Deserialize, Serialize}; +use std::env; +use std::fs; +use std::path::PathBuf; + +pub fn read_config>(config_path: P) -> Result { + let config_path = config_path.into(); + let config = { + let contents = fs::read_to_string(&config_path)?; + deserialize_config(&contents)? + }; + + Ok(config) +} + +fn deserialize_config(contents: &str) -> Result { + let config = toml::from_str(&contents)?; + Ok(config) +} /// The "matrix" section of the config, which gives home server, login information, and etc. #[derive(Serialize, Deserialize, Clone, Debug)] @@ -15,6 +34,11 @@ struct MatrixConfig { const DEFAULT_OLDEST_MESSAGE_AGE: u64 = 15 * 60; +fn db_path_from_env() -> String { + env::var("DATABASE_PATH") + .expect("could not find database path in config or environment variable") +} + /// The "bot" section of the config file, for bot settings. #[derive(Serialize, Deserialize, Clone, Debug)] struct BotConfig { @@ -22,6 +46,21 @@ struct BotConfig { oldest_message_age: Option, } +/// The "database" section of the config file. +#[derive(Serialize, Deserialize, Clone, Debug)] +struct DatabaseConfig { + /// Path to the database storage directory. Required. + path: Option, +} + +impl DatabaseConfig { + #[inline] + #[must_use] + fn path(&self) -> String { + self.path.clone().unwrap_or_else(|| db_path_from_env()) + } +} + impl BotConfig { /// Determine the oldest allowable message age, in seconds. If the /// setting is defined, use that value. If it is not defined, fall @@ -40,6 +79,7 @@ impl BotConfig { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Config { matrix: MatrixConfig, + database: Option, bot: Option, } @@ -65,6 +105,16 @@ impl Config { &self.matrix.password } + /// The path to the database storage directory. + #[inline] + #[must_use] + pub fn database_path(&self) -> String { + self.database + .as_ref() + .map(|db| db.path()) + .unwrap_or_else(|| db_path_from_env()) + } + /// Figure out the allowed oldest message age, in seconds. This will /// be the defined oldest message age in the bot config, if the bot /// configuration and associated "oldest_message_age" setting are @@ -92,6 +142,9 @@ mod tests { username: "".to_owned(), password: "".to_owned(), }, + database: Some(DatabaseConfig { + path: Some("".to_owned()), + }), bot: Some(BotConfig { oldest_message_age: None, }), @@ -108,9 +161,139 @@ mod tests { username: "".to_owned(), password: "".to_owned(), }, + database: Some(DatabaseConfig { + path: Some("".to_owned()), + }), bot: None, }; assert_eq!(15 * 60, cfg.oldest_message_age()); } + + #[test] + fn db_path_uses_setting_first_test() { + let cfg = Config { + matrix: MatrixConfig { + home_server: "".to_owned(), + username: "".to_owned(), + password: "".to_owned(), + }, + database: Some(DatabaseConfig { + path: Some("the-db-path".to_owned()), + }), + bot: None, + }; + + assert_eq!("the-db-path".to_owned(), cfg.database_path()); + } + + #[test] + fn db_path_uses_env_if_setting_not_defined_test() { + env::set_var("DATABASE_PATH", "the-db-path"); + + let cfg = Config { + matrix: MatrixConfig { + home_server: "".to_owned(), + username: "".to_owned(), + password: "".to_owned(), + }, + database: Some(DatabaseConfig { path: None }), + bot: None, + }; + + assert_eq!("the-db-path".to_owned(), cfg.database_path()); + + env::remove_var("DATABASE_PATH"); + } + + #[test] + fn db_path_uses_env_if_section_not_defined_test() { + env::set_var("DATABASE_PATH", "the-db-path"); + + let cfg = Config { + matrix: MatrixConfig { + home_server: "".to_owned(), + username: "".to_owned(), + password: "".to_owned(), + }, + database: None, + bot: None, + }; + + assert_eq!("the-db-path".to_owned(), cfg.database_path()); + + env::remove_var("DATABASE_PATH"); + } + + use indoc::indoc; + + #[test] + fn deserialize_config_without_bot_section_test() { + let contents = indoc! {" + [matrix] + home_server = 'https://matrix.example.com' + username = 'username' + password = 'password' + + [database] + path = '' + "}; + + let cfg: Result<_, _> = deserialize_config(contents); + assert_eq!(true, cfg.is_ok()); + } + + #[test] + fn deserialize_config_without_oldest_message_setting_test() { + let contents = indoc! {" + [matrix] + home_server = 'https://matrix.example.com' + username = 'username' + password = 'password' + + [database] + path = '' + + [bot] + not_a_real_setting = 2 + "}; + + let cfg: Result<_, _> = deserialize_config(contents); + assert_eq!(true, cfg.is_ok()); + } + + #[test] + fn deserialize_config_without_db_path_setting_test() { + let contents = indoc! {" + [matrix] + home_server = 'https://matrix.example.com' + username = 'username' + password = 'password' + + [database] + not_a_real_setting = 1 + + [bot] + not_a_real_setting = 2 + "}; + + let cfg: Result<_, _> = deserialize_config(contents); + assert_eq!(true, cfg.is_ok()); + } + + #[test] + fn deserialize_config_without_db_section_test() { + let contents = indoc! {" + [matrix] + home_server = 'https://matrix.example.com' + username = 'username' + password = 'password' + + [bot] + not_a_real_setting = 2 + "}; + + let cfg: Result<_, _> = deserialize_config(contents); + assert_eq!(true, cfg.is_ok()); + } } diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..f7be4a3 --- /dev/null +++ b/src/context.rs @@ -0,0 +1,27 @@ +use crate::db::Database; + +/// A context carried through the system providing access to things +/// like the database. +#[derive(Clone)] +pub struct Context<'a> { + pub db: &'a Database, + pub room_id: &'a str, + pub username: &'a str, + pub message_body: &'a str, +} + +impl<'a> Context<'a> { + pub fn new( + db: &'a Database, + room_id: &'a str, + username: &'a str, + message_body: &'a str, + ) -> Context<'a> { + Context { + db: db, + room_id: room_id, + username: username, + message_body: message_body, + } + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..1cae17b --- /dev/null +++ b/src/db.rs @@ -0,0 +1,82 @@ +use byteorder::LittleEndian; +use sled::{Db, IVec}; +use thiserror::Error; +use zerocopy::byteorder::I32; +use zerocopy::{AsBytes, LayoutVerified}; + +#[derive(Clone)] +pub struct Database { + db: Db, +} + +#[derive(Error, Debug)] +pub enum DataError { + #[error("value does not exist for key: {0}")] + KeyDoesNotExist(String), + + #[error("internal database error: {0}")] + InternalError(#[from] sled::Error), +} + +fn to_key(room_id: &str, username: &str, variable_name: &str) -> Vec { + let mut key = vec![]; + key.extend_from_slice(room_id.as_bytes()); + key.extend_from_slice(username.as_bytes()); + key.extend_from_slice(variable_name.as_bytes()); + key +} + +impl Database { + pub fn new(db: &Db) -> Database { + Database { db: db.clone() } + } + + pub fn get_user_variable( + &self, + room_id: &str, + username: &str, + variable_name: &str, + ) -> Result, DataError> { + let key = to_key(room_id, username, variable_name); + + if let Some(raw_value) = self.db.get(key)? { + let layout: LayoutVerified<&[u8], I32> = + LayoutVerified::new_unaligned(&*raw_value).expect("bytes do not fit schema"); + + let value: I32 = *layout; + Ok(Some(value.get())) + } else { + Ok(None) + } + } + + pub fn set_user_variable( + &self, + room_id: &str, + username: &str, + variable_name: &str, + value: i32, + ) -> Result<(), DataError> { + let key = to_key(room_id, username, variable_name); + let db_value: I32 = I32::new(value); + self.db.insert(key, IVec::from(db_value.as_bytes()))?; + Ok(()) + } + + pub fn delete_user_variable( + &self, + room_id: &str, + username: &str, + variable_name: &str, + ) -> Result<(), DataError> { + let key = to_key(room_id, username, variable_name); + if let Some(_) = self.db.remove(key)? { + Ok(()) + } else { + let mut key = room_id.to_owned(); + key.push_str(username); + key.push_str(variable_name); + Err(DataError::KeyDoesNotExist(key)) + } + } +} diff --git a/src/error.rs b/src/error.rs index 2afbde8..cf1beb8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,11 +1,39 @@ +use crate::db::DataError; use thiserror::Error; +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("i/o error: {0}")] + IoError(#[from] std::io::Error), + + #[error("toml parsing error: {0}")] + TomlParsingError(#[from] toml::de::Error), +} + +#[derive(Error, Debug)] +pub enum CommandError { + #[error("invalid command: {0}")] + InvalidCommand(String), + + #[error("ignored command")] + IgnoredCommand, +} + #[derive(Error, Debug)] pub enum BotError { + #[error("configuration error: {0}")] + ConfigurationError(#[from] ConfigError), + /// Sync token couldn't be found. #[error("the sync token could not be retrieved")] SyncTokenRequired, + #[error("command error: {0}")] + CommandError(#[from] CommandError), + + #[error("database error: {0}")] + DataError(#[from] DataError), + #[error("the message should not be processed because it failed validation")] ShouldNotProcessError, @@ -31,15 +59,21 @@ pub enum BotError { #[error("toml parsing error")] TomlParsingError(#[from] toml::de::Error), - #[error("i/o error")] + #[error("i/o error: {0}")] IoError(#[from] std::io::Error), + #[error("actor mailbox error: {0}")] + ActixMailboxError(#[from] actix::MailboxError), + #[error("parsing error")] ParserError(#[from] combine::error::StringStreamError), - #[error("dice parsing error")] + #[error("dice parsing error: {0}")] DiceParsingError(#[from] crate::cofd::parser::DiceParsingError), + #[error("variable parsing error: {0}")] + VariableParsingError(#[from] crate::variables::VariableParsingError), + #[error("legacy parsing error")] NomParserError(nom::error::ErrorKind), @@ -48,4 +82,7 @@ pub enum BotError { #[error("variables not yet supported")] VariablesNotSupported, + + #[error("database error")] + DatabaseErrror(#[from] sled::Error), } diff --git a/src/lib.rs b/src/lib.rs index 503e5d7..a213379 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,13 @@ +pub mod actors; pub mod bot; pub mod cofd; pub mod commands; pub mod config; +pub mod context; +pub mod db; pub mod dice; pub mod error; mod help; mod parser; pub mod roll; -pub mod state; +pub mod variables; diff --git a/src/variables.rs b/src/variables.rs new file mode 100644 index 0000000..f7b4319 --- /dev/null +++ b/src/variables.rs @@ -0,0 +1,43 @@ +use crate::error::BotError; +use combine::parser::char::{digit, letter, spaces}; +use combine::{many1, Parser}; +use thiserror::Error; + +enum ParsedValue { + Valid(i32), + Invalid, +} + +#[derive(Error, Debug)] +pub enum VariableParsingError { + #[error("invalid variable value, must be a number")] + InvalidValue, + + #[error("unconsumed input")] + UnconsumedInput, +} + +pub fn parse_set_variable(input: &str) -> Result<(String, i32), BotError> { + let name = many1(letter()).map(|value: String| value); + + let value = many1(digit()).map(|value: String| match value.parse::() { + Ok(num) => ParsedValue::Valid(num), + _ => ParsedValue::Invalid, + }); + + let mut parser = name.skip(spaces().silent()).and(value); + let (result, rest) = parser.parse(input)?; + + if rest.len() == 0 { + match result { + (variable_name, ParsedValue::Valid(value)) => Ok((variable_name, value)), + _ => Err(BotError::VariableParsingError( + VariableParsingError::InvalidValue, + )), + } + } else { + Err(BotError::VariableParsingError( + VariableParsingError::UnconsumedInput, + )) + } +}