Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
489 changes: 392 additions & 97 deletions Cargo.lock

Large diffs are not rendered by default.

44 changes: 23 additions & 21 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "discord-bot"
description = "A WASI plugin based Discord bot, configurable through YAML."
name = "wpbs"
description = "WASM plugin based services."
version = "0.1.0"
authors = ["Eduard Smet <contact@celarye.dev>"]
license = "GPL-3.0-or-later"
Expand All @@ -9,24 +9,25 @@ edition = "2024"
[features]

[dependencies]
anyhow = "1"
bytes = "1"
chrono = "0.4"
clap = { version = "4", features = ["derive"] }
dotenvy = "0.15"
indexmap = "2"
reqwest = { version = "0.13", features = ["hickory-dns"] }
rustls = "0.23"
semver = "1"
serde = "1"
serde_yaml_ng = "0.10" # Should replace this with a better maintained YAML 1.2 supporting alternative
sonic-rs = "0.5"
tokio = { version = "1", features = ["full"] }
tokio-cron-scheduler = { version = "0.15", features = ["english"] }
tokio-util = "0.7"
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-appender = "0.2"
anyhow = "1.0.102"
bytes = "1.11.1"
chrono = "0.4.44"
clap = { version = "4.6.0", features = ["derive"] }
dotenvy = "0.15.7"
fjall = "3.1.2"
indexmap = "2.13.1"
reqwest = { version = "0.13.2", features = ["hickory-dns"] }
rustls = "0.23.37"
semver = "1.0.27"
serde = "1.0.102"
serde_yaml_ng = "0.10.0" # Should replace this with a better maintained YAML 1.2 supporting alternative
sonic-rs = "0.5.8"
tokio = { version = "1.51.0", features = ["full"] }
tokio-cron-scheduler = { version = "0.15.1", features = ["english"] }
tokio-util = "0.7.18"
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
tracing-appender = "0.2.4"
twilight-cache-inmemory = { git = "https://github.com/celarye/twilight/", branch = "feat/form-buffer" }
twilight-gateway = { git = "https://github.com/celarye/twilight/", branch = "feat/form-buffer", features = [
"simd-json",
Expand All @@ -36,7 +37,8 @@ twilight-http = { git = "https://github.com/celarye/twilight/", branch = "feat/f
"hickory",
] }
twilight-model = { git = "https://github.com/celarye/twilight/", branch = "feat/form-buffer" }
url = "2"
url = "2.5.8"
uuid = "1.11.1"
wasmtime = "41"
wasmtime-wasi = "41"
wasmtime-wasi-http = "41"
Expand Down
22 changes: 5 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,17 @@
# Discord Bot
# wbps

A WASI plugin based Discord bot, configurable through YAML.
WASM based plugin services.

## Project Goal

The goal of this project is to create a **Docker Compose-like experience** for
Discord bots.
running services like Discord bots or cron jobs.

Users are able to self-host their own personal bot, assembled from plugins
Users are able to self-host their own personal instance, assembled from plugins
available from the official registry.

This official registry contains plugins covering the most common features
required from Discord bots.
This official registry contains plugins covering the most used features.

Programmers are also able to add their own custom plugins. This allows them
to focus on what really matters (the functionality of that plugin) while
relying on features provided by other plugins.

## To Do List

- [X] Codebase restructure
- [X] Complete the job scheduler
- [X] Implement all WASI host functions
- [X] Implement the Discord request handler
- [ ] Microservice based daemon rewrite
- [ ] Implement all TODOs
- [ ] Add support for all Discord events and requests
- [ ] Make plugins
3 changes: 3 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ clippy:
clippy-fix:
cargo clippy --fix -- -W clippy::pedantic

fmt:
cargo fmt

build-dev:
cargo build

Expand Down
3 changes: 3 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ pub struct Cli {
#[arg(action=ArgAction::Set, default_value_t = true, short = 'C', long, value_name = "BOOL", help = "Enable the usage of cached plugins", long_help = None, hide_possible_values = true)]
pub cache: bool,

#[arg(default_value = "./database", short, long, value_name = "DIRECTORY PATH", help = "The path to the program its database", long_help = None)]
pub database_directory: PathBuf,

#[arg(default_value_t = 15, short = 't', long, value_name = "SECONDS", help = "The amount of seconds after which the HTTP client should timeout", long_help = None)]
pub http_client_timeout_seconds: u64,
}
Expand Down
4 changes: 3 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use indexmap::IndexMap;
use serde::Deserialize;
use tracing::{error, info};

use crate::plugins::ConfigPlugin;
use crate::config::plugins::ConfigPlugin;

pub mod plugins;

#[derive(Deserialize)]
pub struct Config {
Expand Down
19 changes: 19 additions & 0 deletions src/config/plugins.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
use std::collections::HashMap;

use serde::Deserialize;
use sonic_rs::Value;

use crate::config::plugins::permissions::PluginPermissions;

pub mod permissions;

#[derive(Deserialize)]
pub struct ConfigPlugin {
pub plugin: String,
pub cache: Option<bool>,
pub permissions: PluginPermissions,
#[serde(default)]
pub environment: HashMap<String, String>,
#[serde(default)]
pub settings: Value,
}
51 changes: 51 additions & 0 deletions src/config/plugins/permissions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
pub struct PluginPermissions {
#[serde(default)]
pub core: Vec<PluginPermissionsCore>,
#[serde(default)]
pub job_scheduler: Vec<PluginPermissionsJobScheduler>,
#[serde(default)]
pub discord: PluginPermissionsDiscord,
}

#[derive(Default, Deserialize, Serialize)]
pub struct PluginPermissionsDiscord {
pub events: Vec<PluginPermissionsDiscordEvents>,
pub interactions: Vec<PluginPermissionsDiscordInteractions>,
}

#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(untagged)]
pub enum PluginPermissionsCore {
DependencyFunctions,
Shutdown,
}

#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(untagged)]
pub enum PluginPermissionsJobScheduler {
ScheduledJobs,
}

#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(untagged)]
pub enum PluginPermissionsDiscordEvents {
MessageCreate,
InteractionCreate,
ThreadCreate,
ThreadDelete,
ThreadListSync,
ThreadMemberUpdate,
ThreadMembersUpdate,
ThreadUpdate,
}

#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(untagged)]
pub enum PluginPermissionsDiscordInteractions {
ApplicationCommands,
MessageComponents,
Modals,
}
131 changes: 131 additions & 0 deletions src/database.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/* SPDX-License-Identifier: GPL-3.0-or-later */
/* Copyright © 2026 Eduard Smet */

use std::{
fs::{self},
io::ErrorKind,
ops::RangeBounds,
path::Path,
};

use anyhow::{Result, bail};
use fjall::{Database, Iter, KeyspaceCreateOptions, PersistMode, Slice};

use crate::utils::channels::DatabaseMessages;

pub enum Keyspaces {
Plugins, // K: Uuid; AvailablePlugin
PluginStore, // K: String (Uuid-String); V: Vec<u8>
DependencyFunctions, // K: String (registry_id/plugin_id): V: Uuid
ScheduledJobs, // K: Uuid; V: Uuid;
DiscordEvents, // K: String; V: Vec<Uuid>
DiscordApplicationCommands, // K: u64; V: Uuid
DiscordMessageComponents, // K: Uuid; V: Uuid
DiscordModals, // K: Uuid; V: Uuid
}

pub fn new(database_directory_path: &Path) -> Result<Database> {
if let Err(err) = fs::create_dir_all(database_directory_path)
&& err.kind() != ErrorKind::AlreadyExists
{
bail!(err);
}

Ok(Database::builder(database_directory_path).open()?)
}

pub fn handle_action(database: Database, message: DatabaseMessages) {
match message {
DatabaseMessages::Get(keyspace, key, response_sender) => {
response_sender.send(get(database, keyspace, key));
}
DatabaseMessages::GetAllKeys(keyspace, response_sender) => {
response_sender.send(get_all_keys(database, keyspace));
}
DatabaseMessages::GetAllValues(keyspace, response_sender) => {
response_sender.send(get_all_values(database, keyspace));
}
DatabaseMessages::Insert(keyspace, key, value, response_sender) => {
response_sender.send(insert(database, keyspace, key, value));
}
DatabaseMessages::Remove(keyspace, key, response_sender) => {
response_sender.send(remove(database, keyspace, key));
}
DatabaseMessages::ContainsKey(keyspace, key, response_sender) => {
response_sender.send(contains_key(database, keyspace, key));
}
DatabaseMessages::Clear(keyspace, response_sender) => {
response_sender.send(clear(database, keyspace));
}
}
}

pub fn get(database: Database, keyspace: Keyspaces, key: Vec<u8>) -> Result<Option<Slice>> {
let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?;

Ok(keyspace.get(key)?)
}

// TODO: Need to look into how to support this through the MPSC channel
pub fn range<K, R>(database: Database, keyspace: Keyspaces, range: R) -> Result<Iter>
where
K: AsRef<[u8]>,
R: RangeBounds<K>,
{
let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?;

Ok(keyspace.range(range))
}

pub fn get_all_keys(database: Database, keyspace: Keyspaces) -> Result<Vec<Slice>> {
Ok(range(database, keyspace, Vec::new()..=Vec::new())?
.map(|g| g.key())
.collect::<std::result::Result<Vec<Slice>, fjall::Error>>()?)
}

pub fn get_all_values(database: Database, keyspace: Keyspaces) -> Result<Vec<Slice>> {
Ok(range(database, keyspace, Vec::new()..=Vec::new())?
.map(|g| g.value())
.collect::<std::result::Result<Vec<Slice>, fjall::Error>>()?)
}

pub fn insert(database: Database, keyspace: Keyspaces, key: Vec<u8>, value: Vec<u8>) -> Result<()> {
let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?;

Ok(keyspace.insert(key, value)?)
}

pub fn remove(database: Database, keyspace: Keyspaces, key: Vec<u8>) -> Result<()> {
let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?;

Ok(keyspace.remove(key)?)
}

pub fn contains_key(database: Database, keyspace: Keyspaces, key: Vec<u8>) -> Result<bool> {
let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?;

Ok(keyspace.contains_key(key)?)
}

pub fn clear(database: Database, keyspace: Keyspaces) -> Result<()> {
let keyspace = database.keyspace(get_keyspace(keyspace), KeyspaceCreateOptions::default)?;

Ok(keyspace.clear()?)
}

pub fn persist(database: Database, persist_mode: PersistMode) -> Result<()> {
Ok(database.persist(persist_mode)?)
}

fn get_keyspace(keyspace: Keyspaces) -> &'static str {
match keyspace {
Keyspaces::Plugins => "plugins",
Keyspaces::PluginStore => "plugin_store",
Keyspaces::DependencyFunctions => "dependency_functions",
Keyspaces::ScheduledJobs => "scheduled_jobs",
Keyspaces::DiscordEvents => "discord_events",
Keyspaces::DiscordApplicationCommands => "discord_application_commands",
Keyspaces::DiscordMessageComponents => "discord_message_componets",
Keyspaces::DiscordModals => "discord_modals",
}
}
Loading
Loading