Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .devcontainer/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres
POSTGRES_HOSTNAME=localhost
POSTGRES_PORT=5432
6 changes: 6 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -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"
}
37 changes: 37 additions & 0 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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.)
Binary file added .github/assets/portrait.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 10 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "lum"
version = "0.1.0"
version = "0.2.1"
edition = "2021"
description = "Lum Discord Bot"
license= "MIT"
Expand All @@ -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"] }
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
<p align="center">
<img src="https://raw.githubusercontent.com/Kitt3120/lum/main/.github/assets/portrait.png" width="512">
</p>

# lum

Lum Discord Bot

# Deployment
## Deployment

Stable: [![Deploy](https://github.com/Kitt3120/lum/actions/workflows/deploy_release.yml/badge.svg)](https://github.com/Kitt3120/lum/actions/workflows/deploy_release.yml)

Beta: [![Deploy](https://github.com/Kitt3120/lum/actions/workflows/deploy_prerelease.yml/badge.svg)](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)
2 changes: 1 addition & 1 deletion build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
}
}
1 change: 1 addition & 0 deletions rustfmt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
max_width = 110
124 changes: 124 additions & 0 deletions src/bot.rs
Original file line number Diff line number Diff line change
@@ -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<Mutex<dyn Service>>) -> 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<Arc<Mutex<dyn Service>>>) -> 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<ServiceManager>,
}

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,
}
}
}
129 changes: 129 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf, ConfigPathError> {
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<PathBuf, ConfigPathError> {
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<Config, ConfigParseError> {
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)
}
}
Loading
Loading