forked from projectmoon/tenebrous-dicebot
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:
parent
35485cdfc8
commit
6cdc465a2e
|
@ -5,3 +5,4 @@ todo.org
|
||||||
cache
|
cache
|
||||||
*.tar
|
*.tar
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
test-db/
|
||||||
|
|
|
@ -283,9 +283,11 @@ dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
"actix-rt",
|
"actix-rt",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"byteorder",
|
||||||
"combine",
|
"combine",
|
||||||
"dirs",
|
"dirs",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
"futures 0.3.6",
|
||||||
"indoc",
|
"indoc",
|
||||||
"itertools",
|
"itertools",
|
||||||
"log",
|
"log",
|
||||||
|
@ -296,10 +298,12 @@ dependencies = [
|
||||||
"nom",
|
"nom",
|
||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
|
"sled",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"url",
|
"url",
|
||||||
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -391,6 +395,15 @@ version = "0.7.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac"
|
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]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
|
@ -401,6 +414,21 @@ dependencies = [
|
||||||
"maybe-uninit",
|
"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]]
|
[[package]]
|
||||||
name = "crossbeam-queue"
|
name = "crossbeam-queue"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
@ -548,6 +576,16 @@ dependencies = [
|
||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "fuchsia-zircon"
|
name = "fuchsia-zircon"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
|
@ -692,6 +730,15 @@ dependencies = [
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fxhash"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.1.15"
|
version = "0.1.15"
|
||||||
|
@ -1197,6 +1244,15 @@ version = "2.3.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400"
|
checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memoffset"
|
||||||
|
version = "0.5.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "043175f069eda7b85febe4a74abbaeff828d9f8b448515d3151a14a3542811aa"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.16"
|
version = "0.3.16"
|
||||||
|
@ -2007,6 +2063,22 @@ version = "0.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8"
|
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]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
|
@ -2656,6 +2728,27 @@ dependencies = [
|
||||||
"winapi-build",
|
"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]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
|
|
|
@ -25,6 +25,11 @@ indoc = "1.0"
|
||||||
actix = "0.10"
|
actix = "0.10"
|
||||||
actix-rt = "1.1"
|
actix-rt = "1.1"
|
||||||
combine = "4.3"
|
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
|
# 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.
|
# macros are on master, but it imports the common and base from 0.1.0.
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# Builder image with development dependencies.
|
# Builder image with development dependencies.
|
||||||
FROM bougyman/voidlinux:glibc as builder
|
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 base-devel rust cargo cmake wget gnupg
|
||||||
RUN xbps-install -Sy libressl-devel olm-devel libstdc++-devel
|
RUN xbps-install -Sy libressl-devel olm-devel libstdc++-devel
|
||||||
|
|
||||||
|
@ -36,4 +37,5 @@ COPY --from=builder \
|
||||||
/usr/local/bin/
|
/usr/local/bin/
|
||||||
|
|
||||||
ENV XDG_CACHE_HOME "/cache"
|
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" ]
|
ENTRYPOINT [ "/usr/local/bin/tini", "-v", "--", "/usr/local/bin/dicebot", "/config/dicebot-config.toml" ]
|
||||||
|
|
52
README.md
52
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
|
The bot supports a `!help` command for basic help information about
|
||||||
its capabilities.
|
its capabilities.
|
||||||
|
|
||||||
|
### Basic Dice Rolling
|
||||||
The commands `!roll` and `!r` can handle arbitrary dice roll
|
The commands `!roll` and `!r` can handle arbitrary dice roll
|
||||||
expressions.
|
expressions.
|
||||||
|
|
||||||
|
@ -84,26 +85,49 @@ expressions.
|
||||||
!r 3d12 - 5d2 + 3 - 7d3 + 20d20
|
!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
|
The commands `!pool` (or `!rp`) and `!chance` are for the Storytelling
|
||||||
System, and they use a specific syntax to support the dice system. The
|
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
|
simplest version of the command is `!pool <num>` to roll a pool of the
|
||||||
given size using the most common type of roll.
|
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
|
the number, for 9-again, 8-again, and rote quality rolls. The number
|
||||||
of successes required for an exceptional success can be controlled by
|
of successes required for an exceptional success can be controlled by
|
||||||
`s<num>`, e.g. `s3` to only need 3 successes for an exceptional
|
`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:
|
Examples:
|
||||||
|
|
||||||
```
|
```
|
||||||
!pool 8 //regular pool of 8 dice
|
!pool 8 //regular pool of 8 dice
|
||||||
!pool 8n //roll 8 dice, 9-again
|
!pool n:8 //roll 8 dice, 9-again
|
||||||
!pool 8ns3 //roll 8 dice, 9-again with only 3 successes for exceptional
|
!pool ns3:8 //roll 8 dice, 9-again with only 3 successes for exceptional
|
||||||
!pool 5rs2 //5 dice, rote quality, 2 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
|
## Running the Bot
|
||||||
|
|
||||||
The easiest way to run the bot is to use the [official Docker
|
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
|
The Docker image requires two volume mounts: the location of the
|
||||||
[config file][config-file], which should be mounted at `/config/dicebot-config.toml`,
|
[config file][config-file], which should be mounted at
|
||||||
and a cache directory to store client state after initial sync. That
|
`/config/dicebot-config.toml`, and a cache directory to store the
|
||||||
should be mounted at `/cache/`in the container.
|
database and client state after initial sync. That should be mounted
|
||||||
|
at `/cache/`in the container.
|
||||||
|
|
||||||
### Configuration File
|
### Configuration File
|
||||||
|
|
||||||
The configuration file is a TOML file with two sections.
|
The configuration file is a TOML file with three sections.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[matrix]
|
[matrix]
|
||||||
|
@ -137,6 +162,9 @@ home_server = 'https://example.com'
|
||||||
username = 'thisismyusername'
|
username = 'thisismyusername'
|
||||||
password = 'thisismypassword'
|
password = 'thisismypassword'
|
||||||
|
|
||||||
|
[datbase]
|
||||||
|
path = '/path/to/database/directory/'
|
||||||
|
|
||||||
[bot]
|
[bot]
|
||||||
oldest_message_age = 300
|
oldest_message_age = 300
|
||||||
```
|
```
|
||||||
|
@ -151,6 +179,12 @@ bot's matrix account.
|
||||||
- `username`: Bot account username.
|
- `username`: Bot account username.
|
||||||
- `password`: Bot account password.
|
- `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.
|
The `[bot]` section has settings for controlling how the bot operates.
|
||||||
This section is optional and the settings will fall back to their
|
This section is optional and the settings will fall back to their
|
||||||
default values if the section or setting is not present.
|
default values if the section or setting is not present.
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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> {
|
fn main() -> Result<(), BotError> {
|
||||||
let command = std::env::args().skip(1).collect::<Vec<String>>().join(" ");
|
let db = Database::new(&sled::open("test-db")?);
|
||||||
let command = match parse_command(&command) {
|
let input = std::env::args().skip(1).collect::<Vec<String>>().join(" ");
|
||||||
Ok(Some(command)) => command,
|
let command = match Command::parse(&input) {
|
||||||
Ok(None) => return Err("Command not recognized".into()),
|
Ok(command) => command,
|
||||||
Err(e) => return Err(format!("Error parsing command: {}", e)),
|
Err(e) => return Err(e),
|
||||||
};
|
};
|
||||||
println!("{}", command.execute().plain());
|
|
||||||
|
let context = Context::new(&db, "roomid", "localuser", &input);
|
||||||
|
println!("{}", command.execute(&context).plain());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,51 +1,36 @@
|
||||||
//Needed for nested Result handling from tokio. Probably can go away after 1.47.0.
|
//Needed for nested Result handling from tokio. Probably can go away after 1.47.0.
|
||||||
#![type_length_limit = "7605144"]
|
#![type_length_limit = "7605144"]
|
||||||
use actix::prelude::*;
|
use actix::prelude::*;
|
||||||
|
use chronicle_dicebot::actors::Actors;
|
||||||
use chronicle_dicebot::bot::DiceBot;
|
use chronicle_dicebot::bot::DiceBot;
|
||||||
use chronicle_dicebot::config::*;
|
use chronicle_dicebot::config::*;
|
||||||
|
use chronicle_dicebot::db::Database;
|
||||||
use chronicle_dicebot::error::BotError;
|
use chronicle_dicebot::error::BotError;
|
||||||
use chronicle_dicebot::state::DiceBotState;
|
|
||||||
use env_logger::Env;
|
use env_logger::Env;
|
||||||
use log::error;
|
use log::error;
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
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]
|
#[actix_rt::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
env_logger::from_env(Env::default().default_filter_or("chronicle_dicebot=info,dicebot=info"))
|
||||||
|
.init();
|
||||||
match run().await {
|
match run().await {
|
||||||
Ok(_) => (),
|
Ok(_) => (),
|
||||||
Err(e) => error!("Error: {:?}", e),
|
Err(e) => error!("Error: {}", e),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run() -> Result<(), BotError> {
|
async fn run() -> Result<(), BotError> {
|
||||||
env_logger::from_env(Env::default().default_filter_or("chronicle_dicebot=info")).init();
|
|
||||||
|
|
||||||
let config_path = std::env::args()
|
let config_path = std::env::args()
|
||||||
.skip(1)
|
.skip(1)
|
||||||
.next()
|
.next()
|
||||||
.expect("Need a config as an argument");
|
.expect("Need a config as an argument");
|
||||||
|
|
||||||
let cfg = Arc::new(read_config(config_path)?);
|
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?,
|
Ok(bot) => bot.run().await?,
|
||||||
Err(e) => println!("Error connecting: {:?}", e),
|
Err(e) => println!("Error connecting: {:?}", e),
|
||||||
};
|
};
|
||||||
|
@ -53,38 +38,3 @@ async fn run() -> Result<(), BotError> {
|
||||||
System::current().stop();
|
System::current().stop();
|
||||||
Ok(())
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
59
src/bot.rs
59
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::config::*;
|
||||||
|
use crate::context::Context;
|
||||||
|
use crate::db::Database;
|
||||||
use crate::error::BotError;
|
use crate::error::BotError;
|
||||||
use crate::state::{DiceBotState, LogSkippedOldMessages};
|
|
||||||
use actix::Addr;
|
|
||||||
use dirs;
|
use dirs;
|
||||||
use log::{debug, error, info, trace, warn};
|
use log::{debug, error, info, trace, warn};
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
|
@ -31,9 +33,11 @@ pub struct DiceBot {
|
||||||
/// The matrix client.
|
/// The matrix client.
|
||||||
client: Client,
|
client: Client,
|
||||||
|
|
||||||
/// Current state of the dice bot. Actor ref to the core state
|
/// Actors used by the dice bot to manage internal state.
|
||||||
/// actor.
|
actors: Actors,
|
||||||
state: Addr<DiceBotState>,
|
|
||||||
|
/// Active database layer
|
||||||
|
db: Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cache_dir() -> Result<PathBuf, BotError> {
|
fn cache_dir() -> Result<PathBuf, BotError> {
|
||||||
|
@ -57,11 +61,12 @@ impl DiceBot {
|
||||||
/// actor. This function returns a Result because it is possible
|
/// actor. This function returns a Result because it is possible
|
||||||
/// for client creation to fail for some reason (e.g. invalid
|
/// for client creation to fail for some reason (e.g. invalid
|
||||||
/// homeserver URL).
|
/// 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 {
|
Ok(DiceBot {
|
||||||
client: create_client(&config)?,
|
client: create_client(&config)?,
|
||||||
config: config.clone(),
|
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,
|
bot: &DiceBot,
|
||||||
event: &SyncMessageEvent<MessageEventContent>,
|
event: &SyncMessageEvent<MessageEventContent>,
|
||||||
) -> Result<(String, String), BotError> {
|
) -> Result<(String, String), BotError> {
|
||||||
//Ignore messages that are older than configured duration.
|
//Ignore messages that are older than configured duration.
|
||||||
if !check_message_age(event, bot.config.oldest_message_age()) {
|
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 {
|
if let Err(e) = res {
|
||||||
error!("Actix error: {:?}", e);
|
error!("Actix error: {:?}", e);
|
||||||
|
@ -209,37 +214,29 @@ impl EventEmitter for DiceBot {
|
||||||
return;
|
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.
|
//we clone here to hold the lock for as little time as possible.
|
||||||
let (room_name, room_id) = {
|
let (room_name, room_id) = {
|
||||||
let real_room = room.read().await;
|
let real_room = room.read().await;
|
||||||
(real_room.display_name().clone(), real_room.room_id.clone())
|
(real_room.display_name().clone(), real_room.room_id.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = self.client.room_send(&room_id, content, None).await;
|
let ctx = Context::new(&self.db, &room_id.as_str(), &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 {
|
if let Err(e) = result {
|
||||||
error!("Error sending message: {}", e.to_string());
|
error!("Error sending message: {}", e.to_string());
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("[{}] {} executed: {}", room_name, sender_username, msg_body);
|
info!(
|
||||||
|
"[{}] {} executed: {}",
|
||||||
|
room_name, ctx.username, ctx.message_body
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
183
src/commands.rs
183
src/commands.rs
|
@ -1,6 +1,7 @@
|
||||||
use crate::cofd::dice::DicePool;
|
use crate::cofd::dice::DicePool;
|
||||||
|
use crate::context::Context;
|
||||||
use crate::dice::ElementExpression;
|
use crate::dice::ElementExpression;
|
||||||
use crate::error::BotError;
|
use crate::error::{BotError, CommandError};
|
||||||
use crate::help::HelpTopic;
|
use crate::help::HelpTopic;
|
||||||
use crate::roll::Roll;
|
use crate::roll::Roll;
|
||||||
|
|
||||||
|
@ -22,7 +23,7 @@ impl Execution {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Command {
|
pub trait Command {
|
||||||
fn execute(&self) -> Execution;
|
fn execute(&self, ctx: &Context) -> Execution;
|
||||||
fn name(&self) -> &'static str;
|
fn name(&self) -> &'static str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,7 +34,7 @@ impl Command for RollCommand {
|
||||||
"roll regular dice"
|
"roll regular dice"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self) -> Execution {
|
fn execute(&self, _ctx: &Context) -> Execution {
|
||||||
let roll = self.0.roll();
|
let roll = self.0.roll();
|
||||||
let plain = format!("Dice: {}\nResult: {}", self.0, roll);
|
let plain = format!("Dice: {}\nResult: {}", self.0, roll);
|
||||||
let html = format!(
|
let html = format!(
|
||||||
|
@ -51,7 +52,7 @@ impl Command for PoolRollCommand {
|
||||||
"roll dice pool"
|
"roll dice pool"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self) -> Execution {
|
fn execute(&self, _ctx: &Context) -> Execution {
|
||||||
let roll_result = self.0.roll();
|
let roll_result = self.0.roll();
|
||||||
|
|
||||||
let (plain, html) = match roll_result {
|
let (plain, html) = match roll_result {
|
||||||
|
@ -81,7 +82,7 @@ impl Command for HelpCommand {
|
||||||
"help information"
|
"help information"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&self) -> Execution {
|
fn execute(&self, _ctx: &Context) -> Execution {
|
||||||
let help = match &self.0 {
|
let help = match &self.0 {
|
||||||
Some(topic) => topic.message(),
|
Some(topic) => topic.message(),
|
||||||
_ => "There is no help for this topic",
|
_ => "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
|
pub struct GetVariableCommand(String);
|
||||||
/// 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),
|
|
||||||
|
|
||||||
// //TODO replcae with nom all_consuming?
|
impl Command for GetVariableCommand {
|
||||||
// //Any unconsumed input (whitespace should already be
|
fn name(&self) -> &'static str {
|
||||||
// // stripped) is considered a parsing error.
|
"retrieve variable value"
|
||||||
// _ => Err(format!("{}: malformed expression", s)),
|
}
|
||||||
// },
|
|
||||||
// Err(err) => Err(err),
|
fn execute(&self, ctx: &Context) -> Execution {
|
||||||
// }
|
let name = &self.0;
|
||||||
parser::parse_command(s)
|
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)]
|
#[cfg(test)]
|
||||||
|
@ -119,57 +212,39 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chance_die_is_not_malformed() {
|
fn chance_die_is_not_malformed() {
|
||||||
assert!(parse_command("!chance").is_ok());
|
assert!(Command::parse("!chance").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roll_malformed_expression_test() {
|
fn roll_malformed_expression_test() {
|
||||||
assert!(parse_command("!roll 1d20asdlfkj").is_err());
|
assert!(Command::parse("!roll 1d20asdlfkj").is_err());
|
||||||
assert!(parse_command("!roll 1d20asdlfkj ").is_err());
|
assert!(Command::parse("!roll 1d20asdlfkj ").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roll_dice_pool_malformed_expression_test() {
|
fn roll_dice_pool_malformed_expression_test() {
|
||||||
assert!(parse_command("!pool 8abc").is_err());
|
assert!(Command::parse("!pool 8abc").is_err());
|
||||||
assert!(parse_command("!pool 8abc ").is_err());
|
assert!(Command::parse("!pool 8abc ").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pool_whitespace_test() {
|
fn pool_whitespace_test() {
|
||||||
assert!(parse_command("!pool ns3:8 ")
|
Command::parse("!pool ns3:8 ").expect("was error");
|
||||||
.map(|p| p.is_some())
|
Command::parse(" !pool ns3:8").expect("was error");
|
||||||
.expect("was error"));
|
Command::parse(" !pool ns3:8 ").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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_whitespace_test() {
|
fn help_whitespace_test() {
|
||||||
assert!(parse_command("!help stuff ")
|
Command::parse("!help stuff ").expect("was error");
|
||||||
.map(|p| p.is_some())
|
Command::parse(" !help stuff").expect("was error");
|
||||||
.expect("was error"));
|
Command::parse(" !help stuff ").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"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roll_whitespace_test() {
|
fn roll_whitespace_test() {
|
||||||
assert!(parse_command("!roll 1d4 + 5d6 -3 ")
|
Command::parse("!roll 1d4 + 5d6 -3 ").expect("was error");
|
||||||
.map(|p| p.is_some())
|
Command::parse("!roll 1d4 + 5d6 -3 ").expect("was error");
|
||||||
.expect("was error"));
|
Command::parse(" !roll 1d4 + 5d6 -3 ").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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
use crate::cofd::parser::{create_chance_die, parse_dice_pool};
|
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::dice::parser::parse_element_expression;
|
||||||
use crate::error::BotError;
|
use crate::error::BotError;
|
||||||
use crate::help::parse_help_topic;
|
use crate::help::parse_help_topic;
|
||||||
|
use crate::variables::parse_set_variable;
|
||||||
use combine::parser::char::{char, letter, space};
|
use combine::parser::char::{char, letter, space};
|
||||||
use combine::{any, many1, optional, Parser};
|
use combine::{any, many1, optional, Parser};
|
||||||
use nom::Err as NomErr;
|
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> {
|
fn parse_pool_roll(input: &str) -> Result<Box<dyn Command>, BotError> {
|
||||||
let pool = parse_dice_pool(input)?;
|
let pool = parse_dice_pool(input)?;
|
||||||
Ok(Box::new(PoolRollCommand(pool)))
|
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> {
|
pub fn parse_command(input: &str) -> Result<Option<Box<dyn Command>>, BotError> {
|
||||||
match split_command(input) {
|
match split_command(input) {
|
||||||
Ok((cmd, cmd_input)) => match cmd.as_ref() {
|
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)),
|
"r" | "roll" => parse_roll(&cmd_input).map(|command| Some(command)),
|
||||||
"rp" | "pool" => parse_pool_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)),
|
"chance" => chance_die().map(|command| Some(command)),
|
||||||
|
|
183
src/config.rs
183
src/config.rs
|
@ -1,4 +1,23 @@
|
||||||
|
use crate::error::ConfigError;
|
||||||
use serde::{self, Deserialize, Serialize};
|
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.
|
/// The "matrix" section of the config, which gives home server, login information, and etc.
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
@ -15,6 +34,11 @@ struct MatrixConfig {
|
||||||
|
|
||||||
const DEFAULT_OLDEST_MESSAGE_AGE: u64 = 15 * 60;
|
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.
|
/// The "bot" section of the config file, for bot settings.
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
struct BotConfig {
|
struct BotConfig {
|
||||||
|
@ -22,6 +46,21 @@ struct BotConfig {
|
||||||
oldest_message_age: Option<u64>,
|
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 {
|
impl BotConfig {
|
||||||
/// Determine the oldest allowable message age, in seconds. If the
|
/// Determine the oldest allowable message age, in seconds. If the
|
||||||
/// setting is defined, use that value. If it is not defined, fall
|
/// setting is defined, use that value. If it is not defined, fall
|
||||||
|
@ -40,6 +79,7 @@ impl BotConfig {
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
matrix: MatrixConfig,
|
matrix: MatrixConfig,
|
||||||
|
database: Option<DatabaseConfig>,
|
||||||
bot: Option<BotConfig>,
|
bot: Option<BotConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,6 +105,16 @@ impl Config {
|
||||||
&self.matrix.password
|
&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
|
/// Figure out the allowed oldest message age, in seconds. This will
|
||||||
/// be the defined oldest message age in the bot config, if the bot
|
/// be the defined oldest message age in the bot config, if the bot
|
||||||
/// configuration and associated "oldest_message_age" setting are
|
/// configuration and associated "oldest_message_age" setting are
|
||||||
|
@ -92,6 +142,9 @@ mod tests {
|
||||||
username: "".to_owned(),
|
username: "".to_owned(),
|
||||||
password: "".to_owned(),
|
password: "".to_owned(),
|
||||||
},
|
},
|
||||||
|
database: Some(DatabaseConfig {
|
||||||
|
path: Some("".to_owned()),
|
||||||
|
}),
|
||||||
bot: Some(BotConfig {
|
bot: Some(BotConfig {
|
||||||
oldest_message_age: None,
|
oldest_message_age: None,
|
||||||
}),
|
}),
|
||||||
|
@ -108,9 +161,139 @@ mod tests {
|
||||||
username: "".to_owned(),
|
username: "".to_owned(),
|
||||||
password: "".to_owned(),
|
password: "".to_owned(),
|
||||||
},
|
},
|
||||||
|
database: Some(DatabaseConfig {
|
||||||
|
path: Some("".to_owned()),
|
||||||
|
}),
|
||||||
bot: None,
|
bot: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(15 * 60, cfg.oldest_message_age());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
41
src/error.rs
41
src/error.rs
|
@ -1,11 +1,39 @@
|
||||||
|
use crate::db::DataError;
|
||||||
use thiserror::Error;
|
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)]
|
#[derive(Error, Debug)]
|
||||||
pub enum BotError {
|
pub enum BotError {
|
||||||
|
#[error("configuration error: {0}")]
|
||||||
|
ConfigurationError(#[from] ConfigError),
|
||||||
|
|
||||||
/// Sync token couldn't be found.
|
/// Sync token couldn't be found.
|
||||||
#[error("the sync token could not be retrieved")]
|
#[error("the sync token could not be retrieved")]
|
||||||
SyncTokenRequired,
|
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")]
|
#[error("the message should not be processed because it failed validation")]
|
||||||
ShouldNotProcessError,
|
ShouldNotProcessError,
|
||||||
|
|
||||||
|
@ -31,15 +59,21 @@ pub enum BotError {
|
||||||
#[error("toml parsing error")]
|
#[error("toml parsing error")]
|
||||||
TomlParsingError(#[from] toml::de::Error),
|
TomlParsingError(#[from] toml::de::Error),
|
||||||
|
|
||||||
#[error("i/o error")]
|
#[error("i/o error: {0}")]
|
||||||
IoError(#[from] std::io::Error),
|
IoError(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("actor mailbox error: {0}")]
|
||||||
|
ActixMailboxError(#[from] actix::MailboxError),
|
||||||
|
|
||||||
#[error("parsing error")]
|
#[error("parsing error")]
|
||||||
ParserError(#[from] combine::error::StringStreamError),
|
ParserError(#[from] combine::error::StringStreamError),
|
||||||
|
|
||||||
#[error("dice parsing error")]
|
#[error("dice parsing error: {0}")]
|
||||||
DiceParsingError(#[from] crate::cofd::parser::DiceParsingError),
|
DiceParsingError(#[from] crate::cofd::parser::DiceParsingError),
|
||||||
|
|
||||||
|
#[error("variable parsing error: {0}")]
|
||||||
|
VariableParsingError(#[from] crate::variables::VariableParsingError),
|
||||||
|
|
||||||
#[error("legacy parsing error")]
|
#[error("legacy parsing error")]
|
||||||
NomParserError(nom::error::ErrorKind),
|
NomParserError(nom::error::ErrorKind),
|
||||||
|
|
||||||
|
@ -48,4 +82,7 @@ pub enum BotError {
|
||||||
|
|
||||||
#[error("variables not yet supported")]
|
#[error("variables not yet supported")]
|
||||||
VariablesNotSupported,
|
VariablesNotSupported,
|
||||||
|
|
||||||
|
#[error("database error")]
|
||||||
|
DatabaseErrror(#[from] sled::Error),
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
|
pub mod actors;
|
||||||
pub mod bot;
|
pub mod bot;
|
||||||
pub mod cofd;
|
pub mod cofd;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod context;
|
||||||
|
pub mod db;
|
||||||
pub mod dice;
|
pub mod dice;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
mod help;
|
mod help;
|
||||||
mod parser;
|
mod parser;
|
||||||
pub mod roll;
|
pub mod roll;
|
||||||
pub mod state;
|
pub mod variables;
|
||||||
|
|
|
@ -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,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(BotError::VariableParsingError(
|
||||||
|
VariableParsingError::UnconsumedInput,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue