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.
This commit is contained in:
projectmoon 2020-10-15 16:52:08 +00:00 committed by ProjectMoon
parent 35485cdfc8
commit 6cdc465a2e
18 changed files with 749 additions and 167 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ todo.org
cache
*.tar
*.tar.gz
test-db/

93
Cargo.lock generated
View File

@ -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"

View File

@ -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.

View File

@ -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" ]

View File

@ -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 <num>` 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<num>`, 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.

25
src/actors.rs Normal file
View File

@ -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<DiceBotState>,
}
impl Actors {
pub fn new(config: &Arc<Config>, _db: &Database) -> Actors {
let global_state = DiceBotState::new(&config);
Actors {
global_state: global_state.start(),
}
}
pub fn global_state(&self) -> Addr<DiceBotState> {
self.global_state.clone()
}
}

View File

@ -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::<Vec<String>>().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::<Vec<String>>().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(())
}

View File

@ -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<P: Into<PathBuf>>(config_path: P) -> Result<Config, BotError> {
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<Config, BotError> {
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());
}
}

View File

@ -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<DiceBotState>,
/// Actors used by the dice bot to manage internal state.
actors: Actors,
/// Active database layer
db: Database,
}
fn cache_dir() -> Result<PathBuf, BotError> {
@ -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<Config>, state_actor: Addr<DiceBotState>) -> Result<Self, BotError> {
pub fn new(config: &Arc<Config>, actors: Actors, db: &Database) -> Result<Self, BotError> {
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<MessageEventContent>,
) -> 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!("<p><strong>{}</strong></p>", message);
(message, html_message)
}
};
let plain = format!("{}\n{}", sender_username, plain);
let html = format!("<p>{}</p>\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
);
}
}
}
}

View File

@ -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<Option<Box<dyn Command>>, 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!("<p><strong>Variable:</strong> {}", 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!("<p><strong>Set Variable:</strong> {}", 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!("<p><strong>Remove Variable:</strong> {}", 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<Box<dyn Command>, 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<CommandResult> {
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!("<p><strong>{}</strong></p>", message);
(message, html_message)
}
};
let plain = format!("{}\n{}", ctx.username, plain);
let html = format!("<p>{}</p>\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");
}
}

View File

@ -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<Box<dyn Command>, BotError> {
}
}
fn parse_get_variable_command(input: &str) -> Result<Box<dyn Command>, BotError> {
Ok(Box::new(GetVariableCommand(input.to_owned())))
}
fn parse_set_variable_command(input: &str) -> Result<Box<dyn Command>, BotError> {
let (variable_name, value) = parse_set_variable(input)?;
Ok(Box::new(SetVariableCommand(variable_name, value)))
}
fn parse_delete_variable_command(input: &str) -> Result<Box<dyn Command>, BotError> {
Ok(Box::new(DeleteVariableCommand(input.to_owned())))
}
fn parse_pool_roll(input: &str) -> Result<Box<dyn Command>, 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<Option<Box<dyn Command>>, 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)),

View File

@ -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<P: Into<PathBuf>>(config_path: P) -> Result<Config, ConfigError> {
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<Config, ConfigError> {
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<u64>,
}
/// The "database" section of the config file.
#[derive(Serialize, Deserialize, Clone, Debug)]
struct DatabaseConfig {
/// Path to the database storage directory. Required.
path: Option<String>,
}
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<DatabaseConfig>,
bot: Option<BotConfig>,
}
@ -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());
}
}

27
src/context.rs Normal file
View File

@ -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,
}
}
}

82
src/db.rs Normal file
View File

@ -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<u8> {
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<Option<i32>, DataError> {
let key = to_key(room_id, username, variable_name);
if let Some(raw_value) = self.db.get(key)? {
let layout: LayoutVerified<&[u8], I32<LittleEndian>> =
LayoutVerified::new_unaligned(&*raw_value).expect("bytes do not fit schema");
let value: I32<LittleEndian> = *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<LittleEndian> = 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))
}
}
}

View File

@ -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),
}

View File

@ -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;

43
src/variables.rs Normal file
View File

@ -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::<i32>() {
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,
)),
}