diff --git a/.devcontainer/.env b/.devcontainer/.env
new file mode 100644
index 0000000..e4f79bd
--- /dev/null
+++ b/.devcontainer/.env
@@ -0,0 +1,5 @@
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=postgres
+POSTGRES_DB=postgres
+POSTGRES_HOSTNAME=localhost
+POSTGRES_PORT=5432
\ No newline at end of file
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..90b4ce9
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,6 @@
+FROM mcr.microsoft.com/devcontainers/rust:1-1-bullseye
+
+# Include lld linker to improve build times either by using environment variable
+# RUSTFLAGS="-C link-arg=-fuse-ld=lld" or with Cargo's configuration file (i.e see .cargo/config.toml).
+RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
+ && apt-get autoremove -y && apt-get clean -y
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..4266384
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,22 @@
+{
+ "name": "lum",
+ "dockerComposeFile": "docker-compose.yml",
+ "service": "app",
+ "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
+
+ // Comment out the extensions you do not want to install
+ "customizations":{
+ "vscode": {
+ "extensions": [
+ "github.copilot-chat",
+ "github.copilot",
+ "JScearcy.rust-doc-viewer",
+ "swellaby.vscode-rust-test-adapter",
+ "Gruntfuggly.todo-tree",
+ "usernamehw.errorlens"
+ ]
+ }
+ },
+
+ "remoteUser": "vscode"
+}
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
new file mode 100644
index 0000000..2605b33
--- /dev/null
+++ b/.devcontainer/docker-compose.yml
@@ -0,0 +1,37 @@
+version: '3.8'
+
+volumes:
+ postgres-data:
+
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ env_file:
+ # Ensure that the variables in .env match the same variables in devcontainer.json
+ - .env
+
+ volumes:
+ - ../..:/workspaces:cached
+
+ # Overrides default command so things don't shut down after the process ends.
+ command: sleep infinity
+
+ # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
+ network_mode: service:db
+
+ # Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
+ # (Adding the "ports" property to this file will not forward from a Codespace.)
+
+ db:
+ image: postgres
+ restart: unless-stopped
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ env_file:
+ # Ensure that the variables in .env match the same variables in devcontainer.json
+ - .env
+
+ # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
+ # (Adding the "ports" property to this file will not forward from a Codespace.)
\ No newline at end of file
diff --git a/.github/assets/portrait.png b/.github/assets/portrait.png
new file mode 100644
index 0000000..02d2ea8
Binary files /dev/null and b/.github/assets/portrait.png differ
diff --git a/Cargo.toml b/Cargo.toml
index d39ef20..6f9421f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "lum"
-version = "0.1.0"
+version = "0.2.1"
edition = "2021"
description = "Lum Discord Bot"
license= "MIT"
@@ -12,7 +12,15 @@ repository = "https://github.com/Kitt3120/lum"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
+dirs = "5.0.1"
+downcast-rs = "1.2.0"
+fern = { version = "0.6.2", features = ["chrono", "colored", "date-based"] }
+humantime = "2.1.0"
+log = { version = "0.4.20", features = ["serde"] }
serde = { version = "1.0.193", features = ["derive"] }
serde_json = "1.0.108"
-sqlx = { version = "0.7.3", features = ["runtime-tokio", "any", "postgres", "mysql", "sqlite", "tls-native-tls", "migrate", "macros", "uuid", "chrono", "json"] }
+serenity = { version = "0.12.0", default-features=false, features = ["builder", "cache", "collector", "client", "framework", "gateway", "http", "model", "standard_framework", "utils", "voice", "default_native_tls", "tokio_task_builder", "unstable_discord_api", "simd_json", "temp_cache", "chrono", "interactions_endpoint"] }
+sqlx = { version = "0.8.0", features = ["runtime-tokio", "any", "postgres", "mysql", "sqlite", "tls-native-tls", "migrate", "macros", "uuid", "chrono", "json"] }
+thiserror = "1.0.52"
tokio = { version = "1.35.1", features = ["full"] }
+uuid = { version = "1.10.0", features = ["fast-rng", "macro-diagnostics", "v4"] }
\ No newline at end of file
diff --git a/README.md b/README.md
index 3756181..2e34a93 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,17 @@
+
+
+
+
# lum
Lum Discord Bot
-# Deployment
+## Deployment
Stable: [](https://github.com/Kitt3120/lum/actions/workflows/deploy_release.yml)
Beta: [](https://github.com/Kitt3120/lum/actions/workflows/deploy_prerelease.yml)
-# Collaborating
-
-A board can be viewed [here](https://github.com/users/Kitt3120/projects/3)
+## Collaborating
-Issues can be viewed [here](https://github.com/Kitt3120/lum/issues)
+Check out [Milestones](https://github.com/Kitt3120/lum/milestones), [Board](https://github.com/users/Kitt3120/projects/3), and [Issues](https://github.com/Kitt3120/lum/issues)
diff --git a/build.rs b/build.rs
index 7609593..d506869 100644
--- a/build.rs
+++ b/build.rs
@@ -2,4 +2,4 @@
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
-}
\ No newline at end of file
+}
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 0000000..521c9cf
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1 @@
+max_width = 110
\ No newline at end of file
diff --git a/src/bot.rs b/src/bot.rs
new file mode 100644
index 0000000..37dce6e
--- /dev/null
+++ b/src/bot.rs
@@ -0,0 +1,124 @@
+use core::fmt;
+use std::{fmt::Display, sync::Arc};
+
+use log::error;
+use tokio::{signal, sync::Mutex};
+
+use crate::service::{
+ types::LifetimedPinnedBoxedFuture, OverallStatus, Service, ServiceManager, ServiceManagerBuilder,
+};
+
+#[derive(Debug, Clone, Copy)]
+pub enum ExitReason {
+ SIGINT,
+ EssentialServiceFailed,
+}
+
+impl Display for ExitReason {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::SIGINT => write!(f, "SIGINT"),
+ Self::EssentialServiceFailed => write!(f, "Essential Service Failed"),
+ }
+ }
+}
+
+pub struct BotBuilder {
+ name: String,
+ service_manager: ServiceManagerBuilder,
+}
+
+impl BotBuilder {
+ pub fn new(name: &str) -> Self {
+ Self {
+ name: name.to_string(),
+ service_manager: ServiceManager::builder(),
+ }
+ }
+
+ pub async fn with_service(mut self, service: Arc>) -> Self {
+ self.service_manager = self.service_manager.with_service(service).await; // The ServiceManagerBuilder itself will warn when adding a service multiple times
+
+ self
+ }
+
+ pub async fn with_services(mut self, services: Vec>>) -> Self {
+ for service in services {
+ self.service_manager = self.service_manager.with_service(service).await;
+ }
+
+ self
+ }
+
+ pub async fn build(self) -> Bot {
+ Bot {
+ name: self.name,
+ service_manager: self.service_manager.build().await,
+ }
+ }
+}
+
+pub struct Bot {
+ pub name: String,
+ pub service_manager: Arc,
+}
+
+impl Bot {
+ pub fn builder(name: &str) -> BotBuilder {
+ BotBuilder::new(name)
+ }
+
+ //TODO: When Rust allows async trait methods to be object-safe, refactor this to use async instead of returning a future
+ pub fn start(&mut self) -> LifetimedPinnedBoxedFuture<'_, ()> {
+ Box::pin(async move {
+ self.service_manager.start_services().await;
+ //TODO: Potential for further initialization here, like modules
+ })
+ }
+
+ //TODO: When Rust allows async trait methods to be object-safe, refactor this to use async instead of returning a future
+ pub fn stop(&mut self) -> LifetimedPinnedBoxedFuture<'_, ()> {
+ Box::pin(async move {
+ self.service_manager.stop_services().await;
+ //TODO: Potential for further deinitialization here, like modules
+ })
+ }
+
+ pub async fn join(&self) -> ExitReason {
+ let name_clone = self.name.clone();
+ let signal_task = tokio::spawn(async move {
+ let name = name_clone;
+
+ let result = signal::ctrl_c().await;
+ if let Err(error) = result {
+ error!(
+ "Error receiving SIGINT: {}. {} will exit ungracefully immediately to prevent undefined behavior.",
+ error, name
+ );
+ panic!("Error receiving SIGINT: {}", error);
+ }
+ });
+
+ let service_manager_clone = self.service_manager.clone();
+ let mut receiver = self
+ .service_manager
+ .on_status_change
+ .event
+ .subscribe_channel("t", 2, true, true)
+ .await;
+ let status_task = tokio::spawn(async move {
+ let service_manager = service_manager_clone;
+ while (receiver.receiver.recv().await).is_some() {
+ let overall_status = service_manager.overall_status().await;
+ if overall_status == OverallStatus::Unhealthy {
+ return;
+ }
+ }
+ });
+
+ tokio::select! {
+ _ = signal_task => ExitReason::SIGINT,
+ _ = status_task => ExitReason::EssentialServiceFailed,
+ }
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..41e4ed1
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,129 @@
+use core::fmt;
+use serde::{Deserialize, Serialize};
+use std::{
+ fmt::{Display, Formatter},
+ fs, io,
+ path::PathBuf,
+};
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum ConfigPathError {
+ #[error("Unable to get OS config directory")]
+ UnknownBasePath,
+}
+
+#[derive(Debug, Error)]
+pub enum ConfigInitError {
+ #[error("Unable to get config path: {0}")]
+ Path(#[from] ConfigPathError),
+ #[error("I/O error: {0}")]
+ IO(#[from] io::Error),
+}
+
+#[derive(Debug, Error)]
+pub enum ConfigParseError {
+ #[error("Unable to get config path: {0}")]
+ Path(#[from] ConfigPathError),
+ #[error("Unable to initialize config: {0}")]
+ Init(#[from] ConfigInitError),
+ #[error("Unable to serialize or deserialize config: {0}")]
+ Serde(#[from] serde_json::Error),
+ #[error("I/O error: {0}")]
+ IO(#[from] io::Error),
+}
+
+fn discord_token_default() -> String {
+ String::from("Please provide a token")
+}
+
+#[derive(Debug, PartialEq, PartialOrd, Serialize, Deserialize, Clone)]
+pub struct Config {
+ #[serde(rename = "discordToken", default = "discord_token_default")]
+ pub discord_token: String,
+}
+
+impl Default for Config {
+ fn default() -> Self {
+ Config {
+ discord_token: discord_token_default(),
+ }
+ }
+}
+
+impl Display for Config {
+ fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+ let content = match serde_json::to_string(self) {
+ Ok(content) => content,
+ Err(error) => {
+ return write!(f, "Unable to serialize config: {}", error);
+ }
+ };
+
+ write!(f, "{}", content)
+ }
+}
+
+#[derive(Debug)]
+pub struct ConfigHandler {
+ pub app_name: String,
+}
+
+impl ConfigHandler {
+ pub fn new(app_name: &str) -> Self {
+ ConfigHandler {
+ app_name: app_name.to_string(),
+ }
+ }
+
+ pub fn get_config_dir_path(&self) -> Result {
+ let mut path = match dirs::config_dir() {
+ Some(path) => path,
+ None => return Err(ConfigPathError::UnknownBasePath),
+ };
+
+ path.push(&self.app_name);
+ Ok(path)
+ }
+
+ pub fn create_config_dir_path(&self) -> Result<(), ConfigInitError> {
+ let path = self.get_config_dir_path()?;
+ fs::create_dir_all(path)?;
+ Ok(())
+ }
+
+ pub fn get_config_file_path(&self) -> Result {
+ let mut path = self.get_config_dir_path()?;
+ path.push("config.json");
+ Ok(path)
+ }
+
+ pub fn save_config(&self, config: &Config) -> Result<(), ConfigParseError> {
+ let path = self.get_config_file_path()?;
+
+ if !path.exists() {
+ self.create_config_dir_path()?;
+ }
+
+ let config_json = serde_json::to_string_pretty(config)?;
+
+ fs::write(path, config_json)?;
+
+ Ok(())
+ }
+
+ pub fn load_config(&self) -> Result {
+ let path = self.get_config_file_path()?;
+ if !path.exists() {
+ self.create_config_dir_path()?;
+ fs::write(&path, "{}")?;
+ }
+
+ let config_json = fs::read_to_string(path)?;
+ let config: Config = serde_json::from_str(&config_json)?;
+
+ self.save_config(&config)?; // In case the config file was missing some fields which serde used the defaults for
+
+ Ok(config)
+ }
+}
diff --git a/src/event.rs b/src/event.rs
new file mode 100644
index 0000000..0eab11f
--- /dev/null
+++ b/src/event.rs
@@ -0,0 +1,13 @@
+pub mod arc_observable;
+pub mod event;
+pub mod event_repeater;
+pub mod observable;
+pub mod subscriber;
+pub mod subscription;
+
+pub use arc_observable::ArcObservable;
+pub use event::Event;
+pub use event_repeater::EventRepeater;
+pub use observable::{Observable, ObservableResult};
+pub use subscriber::{Callback, DispatchError, Subscriber};
+pub use subscription::{ReceiverSubscription, Subscription};
diff --git a/src/event/arc_observable.rs b/src/event/arc_observable.rs
new file mode 100644
index 0000000..b399b67
--- /dev/null
+++ b/src/event/arc_observable.rs
@@ -0,0 +1,60 @@
+use std::{
+ hash::{DefaultHasher, Hash, Hasher},
+ sync::Arc,
+};
+
+use tokio::sync::Mutex;
+
+use super::{Event, ObservableResult};
+
+#[derive(Debug)]
+pub struct ArcObservable
+where
+ T: Send + 'static + Hash,
+{
+ value: Arc>,
+ on_change: Event>,
+}
+
+impl ArcObservable
+where
+ T: Send + 'static + Hash,
+{
+ pub fn new(value: T, event_name: impl Into) -> Self {
+ Self {
+ value: Arc::new(Mutex::new(value)),
+ on_change: Event::new(event_name),
+ }
+ }
+
+ pub async fn get(&self) -> Arc> {
+ Arc::clone(&self.value)
+ }
+
+ pub async fn set(&self, value: T) -> ObservableResult> {
+ let mut lock = self.value.lock().await;
+
+ let mut hasher = DefaultHasher::new();
+ (*lock).hash(&mut hasher);
+ let current_value = hasher.finish();
+
+ let mut hasher = DefaultHasher::new();
+ value.hash(&mut hasher);
+ let new_value = hasher.finish();
+
+ if current_value == new_value {
+ return ObservableResult::Unchanged;
+ }
+
+ *lock = value;
+ drop(lock);
+
+ let value = Arc::clone(&self.value);
+ let dispatch_result = self.on_change.dispatch(value).await;
+
+ match dispatch_result {
+ Ok(_) => ObservableResult::Changed(Ok(())),
+ Err(errors) => ObservableResult::Changed(Err(errors)),
+ }
+ }
+}
diff --git a/src/event/event.rs b/src/event/event.rs
new file mode 100644
index 0000000..d714381
--- /dev/null
+++ b/src/event/event.rs
@@ -0,0 +1,196 @@
+use crate::service::{BoxedError, PinnedBoxedFutureResult};
+use std::{
+ any::type_name,
+ fmt::{self, Debug, Formatter},
+ sync::Arc,
+};
+use tokio::sync::{mpsc::channel, Mutex};
+use uuid::Uuid;
+
+use super::{Callback, DispatchError, ReceiverSubscription, Subscriber, Subscription};
+
+pub struct Event
+where
+ T: Send + Sync + 'static,
+{
+ pub name: String,
+
+ pub uuid: Uuid,
+ subscribers: Mutex>>,
+}
+
+impl Event
+where
+ T: Send + Sync + 'static,
+{
+ pub fn new(name: S) -> Self
+ where
+ S: Into,
+ {
+ Self {
+ name: name.into(),
+ uuid: Uuid::new_v4(),
+ subscribers: Mutex::new(Vec::new()),
+ }
+ }
+
+ pub async fn subscriber_count(&self) -> usize {
+ let subscribers = self.subscribers.lock().await;
+ subscribers.len()
+ }
+
+ pub async fn subscribe_channel(
+ &self,
+ name: S,
+ buffer: usize,
+ log_on_error: bool,
+ remove_on_error: bool,
+ ) -> ReceiverSubscription>
+ where
+ S: Into,
+ {
+ let (sender, receiver) = channel(buffer);
+ let subscriber = Subscriber::new(name, log_on_error, remove_on_error, Callback::Channel(sender));
+
+ let subscription = Subscription::from(&subscriber);
+ let receiver_subscription = ReceiverSubscription::new(subscription, receiver);
+
+ let mut subscribers = self.subscribers.lock().await;
+ subscribers.push(subscriber);
+
+ receiver_subscription
+ }
+
+ pub async fn subscribe_async_closure(
+ &self,
+ name: S,
+ closure: impl Fn(Arc) -> PinnedBoxedFutureResult<()> + Send + Sync + 'static,
+ log_on_error: bool,
+ remove_on_error: bool,
+ ) -> Subscription
+ where
+ S: Into,
+ {
+ let subscriber = Subscriber::new(
+ name,
+ log_on_error,
+ remove_on_error,
+ Callback::AsyncClosure(Box::new(closure)),
+ );
+ let subscription = Subscription::from(&subscriber);
+
+ let mut subscribers = self.subscribers.lock().await;
+ subscribers.push(subscriber);
+
+ subscription
+ }
+
+ pub async fn subscribe_closure(
+ &self,
+ name: S,
+ closure: impl Fn(Arc) -> Result<(), BoxedError> + Send + Sync + 'static,
+ log_on_error: bool,
+ remove_on_error: bool,
+ ) -> Subscription
+ where
+ S: Into,
+ {
+ let subscriber = Subscriber::new(
+ name,
+ log_on_error,
+ remove_on_error,
+ Callback::Closure(Box::new(closure)),
+ );
+ let subscription = Subscription::from(&subscriber);
+
+ let mut subscribers = self.subscribers.lock().await;
+ subscribers.push(subscriber);
+
+ subscription
+ }
+
+ pub async fn unsubscribe(&self, subscription: S) -> Option
+ where
+ S: Into,
+ {
+ let subscription_to_remove = subscription.into();
+
+ let mut subscribers = self.subscribers.lock().await;
+ let index = subscribers
+ .iter()
+ .position(|subscription_of_event| subscription_of_event.uuid == subscription_to_remove.uuid);
+
+ if let Some(index) = index {
+ subscribers.remove(index);
+ None
+ } else {
+ Some(subscription_to_remove)
+ }
+ }
+
+ pub async fn dispatch(&self, data: Arc) -> Result<(), Vec>> {
+ let mut errors = Vec::new();
+ let mut subscribers_to_remove = Vec::new();
+
+ let mut subscribers = self.subscribers.lock().await;
+ for (index, subscriber) in subscribers.iter().enumerate() {
+ let data = Arc::clone(&data);
+
+ let result = subscriber.dispatch(data).await;
+ if let Err(err) = result {
+ if subscriber.log_on_error {
+ log::error!(
+ "Event \"{}\" failed to dispatch data to subscriber {}: {}.",
+ self.name,
+ subscriber.name,
+ err
+ );
+ }
+
+ if subscriber.remove_on_error {
+ if subscriber.log_on_error {
+ log::error!("Subscriber will be unregistered from event.");
+ }
+
+ subscribers_to_remove.push(index);
+ }
+
+ errors.push(err);
+ }
+ }
+
+ for index in subscribers_to_remove.into_iter().rev() {
+ subscribers.remove(index);
+ }
+
+ if errors.is_empty() {
+ Ok(())
+ } else {
+ Err(errors)
+ }
+ }
+}
+
+impl PartialEq for Event
+where
+ T: Send + Sync + 'static,
+{
+ fn eq(&self, other: &Self) -> bool {
+ self.uuid == other.uuid
+ }
+}
+
+impl Eq for Event where T: Send + Sync {}
+
+impl Debug for Event
+where
+ T: Send + Sync + 'static,
+{
+ fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+ f.debug_struct(type_name::())
+ .field("uuid", &self.uuid)
+ .field("name", &self.name)
+ .field("subscribers", &self.subscribers.blocking_lock().len())
+ .finish()
+ }
+}
diff --git a/src/event/event_repeater.rs b/src/event/event_repeater.rs
new file mode 100644
index 0000000..d585df7
--- /dev/null
+++ b/src/event/event_repeater.rs
@@ -0,0 +1,154 @@
+use std::{collections::HashMap, sync::Arc};
+use thiserror::Error;
+use tokio::{sync::Mutex, task::JoinHandle};
+use uuid::Uuid;
+
+use super::{Event, Subscription};
+
+#[derive(Debug, Error)]
+pub enum AttachError {
+ #[error("Tried to attach event {event_name} to EventRepeater {repeater_name} before it was initialized. Did you not use EventRepeater::new()?")]
+ NotInitialized {
+ event_name: String,
+ repeater_name: String,
+ },
+
+ #[error(
+ "Tried to attach event {event_name} to EventRepeater {repeater_name}, which was already attached."
+ )]
+ AlreadyAttached {
+ event_name: String,
+ repeater_name: String,
+ },
+}
+
+#[derive(Debug, Error)]
+pub enum DetachError {
+ #[error(
+ "Tried to detach event {event_name} from EventRepeater {repeater_name}, which was not attached."
+ )]
+ NotAttached {
+ event_name: String,
+ repeater_name: String,
+ },
+}
+
+#[derive(Error)]
+pub enum CloseError
+where
+ T: Send + Sync + 'static,
+{
+ #[error("EventRepeater still has attached events. Detach all events before closing.")]
+ AttachedEvents(EventRepeater),
+}
+
+pub struct EventRepeater
+where
+ T: Send + Sync + 'static,
+{
+ pub event: Event,
+ self_arc: Mutex