Compare commits

..

3 Commits

51 changed files with 2348 additions and 2432 deletions

View File

@ -1,2 +0,0 @@
[registries.gitea]
index = "sparse+https://git.toast-server.net/api/packages/toast/cargo/"

View File

@ -1,11 +1,7 @@
.vscode
.cargo
target
.env
.gitignore
.gitattributes
docker-compose.yml
Dockerfile
renovate.json
run.sh
target/debug/
target/release/

View File

@ -1,91 +1,48 @@
name: Build and push Docker image
name: Build and push container image
on:
push:
branches:
- master
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
RUNNER_TOOL_CACHE: /toolcache
jobs:
build:
runs-on: ubuntu-22.04
runs-on: ubuntu-22.04-node
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Checkout
uses: actions/checkout@v4
- name: Install zstd
run: sudo apt-get update && sudo apt-get install -y zstd
- name: Generate cache key
id: cargo-cache-key
- name: Set up Docker environment
run: |
find ./Cargo.lock -type f -exec sha256sum {} + | sha256sum > hash.txt
cat hash.txt
apt update && apt upgrade -y && apt install -y apt-transport-https ca-certificates curl zstd gnupg lsb-release
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo \
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian \
$(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update && apt install -y docker-ce docker-ce-cli containerd.io
- name: Cache
uses: actions/cache@v4
id: cache
with:
save-always: true
path: |
$HOME/.cargo/bin/
$HOME/.cargo/git/
$HOME/.cargo/registry/index/
$HOME/.cargo/registry/cache/
target/
key: ${{ runner.os }}-cargo-${{ steps.cargo-cache-key.outputs.hash }}
restore-keys: ${{ runner.os }}-cargo-
- name: Checkout branch
uses: https://github.com/actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
- name: Login to Gitea
uses: docker/login-action@v3
uses: https://github.com/docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3
with:
registry: git.toast-server.net
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Set up Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: -C target-feature=-crt-static
- name: Install zigbuild
run: |
pip3 install ziglang
cargo install --locked cargo-zigbuild
- name: Compile
run: |
rustup target add x86_64-unknown-linux-musl
export GIT_COMMIT_HASH=${{ github.sha }} && \
cargo zigbuild --target x86_64-unknown-linux-musl --locked -rF production
- name: Set up Docker Buildx
uses: https://github.com/docker/setup-buildx-action@2b51285047da1547ffb1b2203d8be4c0af6b1f20 # v3
- name: Build and push image
uses: docker/build-push-action@v6
uses: https://github.com/docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
with:
context: .
platforms: linux/amd64
push: true
tags: git.toast-server.net/toast/kon:master
cache-from: type=registry,ref=git.toast-server.net/toast/kon:cache
cache-to: type=registry,ref=git.toast-server.net/toast/kon:cache,mode=max,image-manifest=true,oci-mediatypes=true
tags: git.toast-server.net/toast/kon:main
build-args: |
CARGO_TOKEN=${{ secrets.CARGO_TOKEN }}
deploy:
runs-on: host
needs: build
steps:
- name: Deploy update
uses: appleboy/ssh-action@v1.2.0
- name: Update Delivery
uses: https://github.com/appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USERNAME }}
@ -93,6 +50,6 @@ jobs:
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
cd kon && docker compose pull bot && \
docker compose up -d bot --force-recreate && \
docker system prune -f
cd kon && docker compose pull && \
docker compose down --remove-orphans && docker compose up -d && \
docker image prune -f && docker system prune -f

4
.gitignore vendored
View File

@ -1,6 +1,2 @@
target
.env
*.log
# Local Gitea Actions
act

View File

@ -1,11 +0,0 @@
{
"recommendations": [
"fill-labs.dependi",
"usernamehw.errorlens",
"tamasfe.even-better-toml",
"GitHub.vscode-pull-request-github",
"rust-lang.rust-analyzer",
"redhat.vscode-yaml",
"sumneko.lua"
]
}

View File

@ -1,4 +1,6 @@
{
"rust-analyzer.showUnlinkedFileNotification": false,
"rust-analyzer.linkedProjects": ["./Cargo.toml"]
"rust-analyzer.linkedProjects": [
"./Cargo.toml"
],
"rust-analyzer.showUnlinkedFileNotification": false
}

1985
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,40 +1,23 @@
[package]
name = "kon"
version = "0.6.6"
edition = "2024"
[workspace]
members = ["cmds", "libs", "repo", "tokens"]
[workspace.dependencies]
bb8 = "0.9.0"
bb8-redis = "0.21.0"
cargo_toml = "0.21.0"
dashmap = "6.1.0"
poise = "0.6.1"
reqwest = { version = "0.12.12", features = ["json", "native-tls-vendored"] }
serde = "1.0.218"
serde_json = "1.0.139"
sysinfo = "0.33.1"
lazy_static = "1.5.0"
tokio = { version = "1.42.0", features = ["macros", "signal", "rt-multi-thread"] }
uptime_lib = "0.3.1"
kon_libs = { path = "libs" }
kon_tokens = { path = "tokens" }
kon_repo = { path = "repo" }
version = "0.3.2"
edition = "2021"
[dependencies]
kon_cmds = { path = "cmds" }
kon_libs = { workspace = true }
kon_tokens = { workspace = true }
poise = { workspace = true }
tokio = { workspace = true }
[patch.crates-io]
poise = { git = "https://github.com/serenity-rs/poise", branch = "next" }
[features]
production = ["kon_libs/production"]
cargo_toml = "0.19.2"
gamedig = "0.5.0"
lazy_static = "1.4.0"
once_cell = "1.19.0"
parse_duration = "2.1.1"
poise = "0.6.1"
reqwest = { version = "0.12.3", features = ["json"] }
serde = "1.0.197"
serde_json = "1.0.115"
sysinfo = "0.30.8"
tokenservice-client = { version = "0.2.0", registry = "gitea" }
tokio = { version = "1.37.0", features = ["macros", "signal", "rt-multi-thread"] }
tokio-postgres = "0.7.10"
uptime_lib = "0.3.0"
[[bin]]
name = "kon"
@ -42,6 +25,9 @@ path = "src/main.rs"
[profile.dev]
opt-level = 0
debug = true
[profile.release]
opt-level = 2
debug = false
strip = true

View File

@ -1,10 +1,16 @@
FROM scratch AS base
WORKDIR /builder
FROM rust:1.77-alpine3.19@sha256:d4c2b0a1544462f40b6179aedff4f5485a019a213907c8590ed77d1b6145a29c AS compiler
ENV RUSTFLAGS="-C target-feature=-crt-static"
ARG CARGO_TOKEN
RUN apk add --no-cache openssl-dev musl-dev
WORKDIR /usr/src/kon
COPY . .
RUN mkdir -p .cargo && \
printf '[registries.gitea]\nindex = "sparse+https://git.toast-server.net/api/packages/toast/cargo/"\ntoken = "Bearer %s"\n' "$CARGO_TOKEN" >> .cargo/config.toml
RUN cargo fetch && cargo build -r
FROM adelielinux/adelie:1.0-beta6
LABEL org.opencontainers.image.source="https://git.toast-server.net/toast/Kon"
RUN apk add --no-cache libgcc
FROM alpine:3.19@sha256:c5b1261d6d3e43071626931fc004f70149baeba2c8ec672bd4f27761f8e1ad6b
RUN apk add --no-cache openssl-dev libgcc
WORKDIR /kon
COPY --from=base /builder/target/x86_64-unknown-linux-musl/release/kon .
COPY --from=compiler /usr/src/kon/target/release/kon .
COPY --from=compiler /usr/src/kon/Cargo.toml .
CMD [ "./kon" ]

View File

@ -1,17 +0,0 @@
[package]
name = "kon_cmds"
version = "0.1.4"
edition = "2024"
[dependencies]
kon_libs = { workspace = true }
kon_tokens = { workspace = true }
lazy_static = { workspace = true }
poise = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sysinfo = { workspace = true }
tokio = { workspace = true }
uptime_lib = { workspace = true }
dashmap = { workspace = true }

View File

@ -1,38 +0,0 @@
mod ilo;
mod status;
mod uptime;
use kon_libs::{
KonData,
KonError,
KonResult,
PoiseCtx
};
use {
ilo::ilo,
status::status,
uptime::uptime
};
macro_rules! commands {
($($cmd:ident),*) => {
vec![$($cmd()),*]
}
}
pub fn register_cmds() -> Vec<poise::Command<KonData, KonError>> { commands!(deploy, ping, ilo, status, uptime) }
/// Deploy the commands globally or in a guild
#[poise::command(prefix_command, owners_only, guild_only)]
pub async fn deploy(ctx: PoiseCtx<'_>) -> KonResult<()> {
poise::builtins::register_application_commands_buttons(ctx).await?;
Ok(())
}
/// Check if the bot is alive
#[poise::command(slash_command, install_context = "Guild|User", interaction_context = "Guild|BotDm|PrivateChannel")]
pub async fn ping(ctx: PoiseCtx<'_>) -> KonResult<()> {
ctx.reply(format!("Powong! `{:.2?}`", ctx.ping().await)).await?;
Ok(())
}

View File

@ -1,366 +0,0 @@
use {
dashmap::DashMap,
kon_libs::{
BINARY_PROPERTIES,
KonResult
},
kon_tokens::token_path,
lazy_static::lazy_static,
poise::{
CreateReply,
serenity_prelude::{
CreateEmbed,
Timestamp
}
},
reqwest::{
Client,
ClientBuilder,
Error as ReqError
},
serde::{
Deserialize,
Serialize,
de::DeserializeOwned
},
tokio::time::Duration
};
const ILO_HOSTNAME: &str = "POMNI";
lazy_static! {
static ref REQWEST_CLIENT: Client = ClientBuilder::new()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_secs(15))
.pool_max_idle_per_host(6)
.pool_idle_timeout(Some(Duration::from_secs(30)))
.tcp_keepalive(Duration::from_secs(600))
.build()
.unwrap();
static ref SENSOR_NAMES: DashMap<&'static str, &'static str> = {
let m = DashMap::new();
m.insert("01-Inlet Ambient", "Inlet Ambient");
m.insert("04-P1 DIMM 1-6", "P1 DIMM 1-6");
m.insert("14-Chipset Zone", "Chipset Zone");
m
};
static ref POST_STATES: DashMap<&'static str, &'static str> = {
let m = DashMap::new();
m.insert("FinishedPost", "Finished POST");
m.insert("InPost", "In POST (Booting)");
m.insert("PowerOff", "Powered off");
m
};
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Chassis {
fans: Vec<Fan>,
temperatures: Vec<Temperature>
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Fan {
current_reading: i32,
fan_name: String,
status: Status
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Temperature {
current_reading: i32,
name: String,
reading_celsius: i32,
status: Status,
units: String,
upper_threshold_critical: i32,
upper_threshold_fatal: i32
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Status {
health: Option<String>,
state: String
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct Power {
power_capacity_watts: i32,
power_consumed_watts: i32,
power_metrics: PowerMetrics
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct PowerMetrics {
average_consumed_watts: i32,
max_consumed_watts: i32,
min_consumed_watts: i32
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct System {
memory: Memory,
model: String,
oem: Oem,
power_state: String,
processor_summary: ProcessorSummary
}
#[derive(Serialize, Deserialize, Debug)]
struct Memory {
#[serde(rename = "TotalSystemMemoryGB")]
total_system_memory_gb: i32
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct ProcessorSummary {
count: i32,
model: String
}
#[derive(Serialize, Deserialize, Debug)]
struct Oem {
#[serde(rename = "Hp")]
hp: Hp
}
#[derive(Serialize, Deserialize, Debug)]
struct Hp {
#[serde(rename = "PostState")]
post_state: String
}
#[derive(Serialize, Deserialize)]
struct Event {
#[serde(rename = "Status")]
status: Status
}
#[derive(Serialize, Deserialize)]
/// HP calls this Integrated Management Log
struct Iml {
#[serde(rename = "Items")]
items: Vec<ImlEntry>
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct ImlEntry {
created: String,
message: String,
severity: String
}
enum RedfishEndpoint {
Thermal,
Power,
System,
EventService,
LogServices
}
impl RedfishEndpoint {
fn url(&self) -> String {
match self {
RedfishEndpoint::Thermal => "Chassis/1/Thermal".to_string(),
RedfishEndpoint::Power => "Chassis/1/Power".to_string(),
RedfishEndpoint::System => "Systems/1".to_string(),
RedfishEndpoint::EventService => "EventService".to_string(),
RedfishEndpoint::LogServices => "Systems/1/LogServices/IML/Entries".to_string()
}
}
}
async fn ilo_data<T: DeserializeOwned>(endpoint: RedfishEndpoint) -> Result<T, ReqError> {
let client = &*REQWEST_CLIENT;
let token = token_path().await;
let redfish_url = format!("https://{}/redfish/v1/{}", token.ilo_ip, endpoint.url());
let res = client.get(redfish_url).basic_auth(token.ilo_user, Some(token.ilo_pw)).send().await?;
res.json::<T>().await
}
fn embed_builder(
title: &str,
description: Option<String>,
fields: Option<Vec<(String, String, bool)>>
) -> CreateEmbed {
let mut embed = CreateEmbed::new()
.color(BINARY_PROPERTIES.embed_color)
.timestamp(Timestamp::now())
.title(format!("{ILO_HOSTNAME} - {title}"));
if let Some(d) = description {
embed = embed.description(d);
}
if let Some(f) = fields {
for (name, value, inline) in f {
embed = embed.field(name, value, inline);
}
}
embed
}
fn fmt_dt(input: &str) -> Option<String> {
let parts: Vec<&str> = input.split('T').collect();
if parts.len() != 2 {
return None;
}
let date_parts: Vec<&str> = parts[0].split('-').collect();
if date_parts.len() != 3 {
return None;
}
let date = format!("{}/{}/{}", date_parts[2], date_parts[1], date_parts[0]);
let time = parts[1].trim_end_matches('Z');
Some(format!("{date} {time}"))
}
/// Retrieve data from the HP iLO interface
#[poise::command(
slash_command,
install_context = "Guild|User",
interaction_context = "Guild|BotDm|PrivateChannel",
subcommands("temperature", "power", "system", "logs")
)]
pub async fn ilo(_: super::PoiseCtx<'_>) -> KonResult<()> { Ok(()) }
/// Retrieve the server's temperature data
#[poise::command(slash_command)]
async fn temperature(ctx: super::PoiseCtx<'_>) -> KonResult<()> {
ctx.defer().await?;
let data: Chassis = ilo_data(RedfishEndpoint::Thermal).await?;
let mut tempdata = String::new();
let mut fandata = String::new();
let allowed_sensors = ["01-Inlet Ambient", "04-P1 DIMM 1-6", "14-Chipset Zone"];
for temp in &data.temperatures {
if temp.reading_celsius == 0 || !allowed_sensors.contains(&temp.name.as_str()) {
continue;
}
let name = SENSOR_NAMES.get(temp.name.as_str()).map(|s| *s).unwrap_or("Unknown sensor");
tempdata.push_str(&format!("**{name}:** `{}°C`\n", temp.reading_celsius));
}
for fan in &data.fans {
if fan.current_reading == 0 {
continue;
}
fandata.push_str(&format!("**{}:** `{}%`\n", fan.fan_name, fan.current_reading));
}
ctx
.send(CreateReply::default().embed(embed_builder(
"Temperatures",
None,
Some(vec![("Temperatures".to_string(), tempdata, false), ("Fans".to_string(), fandata, false)])
)))
.await?;
Ok(())
}
/// Retrieve the server's power data
#[poise::command(slash_command)]
async fn power(ctx: super::PoiseCtx<'_>) -> KonResult<()> {
ctx.defer().await?;
let data: Power = ilo_data(RedfishEndpoint::Power).await?;
let powerdata = format!(
"**Power Capacity:** `{}w`\n**Power Consumed:** `{}w`\n**Average Power:** `{}w`\n**Max Consumed:** `{}w`\n**Min Consumed:** `{}w`",
data.power_capacity_watts,
data.power_consumed_watts,
data.power_metrics.average_consumed_watts,
data.power_metrics.max_consumed_watts,
data.power_metrics.min_consumed_watts
);
ctx
.send(CreateReply::default().embed(embed_builder("Power", Some(powerdata), None)))
.await?;
Ok(())
}
/// Retrieve the server's system data
#[poise::command(slash_command)]
async fn system(ctx: super::PoiseCtx<'_>) -> KonResult<()> {
ctx.defer().await?;
let (ilo_sys, ilo_event) = tokio::join!(ilo_data(RedfishEndpoint::System), ilo_data(RedfishEndpoint::EventService));
let ilo_sys: System = ilo_sys.unwrap();
let ilo_event: Event = ilo_event.unwrap();
let mut data = String::new();
let post_state = POST_STATES
.get(ilo_sys.oem.hp.post_state.as_str())
.map(|s| *s)
.unwrap_or("Unknown POST state");
if ilo_sys.oem.hp.post_state != "FinishedPost" {
println!("iLO:PostState = {}", ilo_sys.oem.hp.post_state);
}
data.push_str(&format!(
"**Health:** `{}`\n",
ilo_event.status.health.as_ref().unwrap_or(&"Unknown".to_string())
));
data.push_str(&format!("**POST:** `{post_state}`\n"));
data.push_str(&format!("**Power:** `{}`\n", &ilo_sys.power_state));
data.push_str(&format!("**Model:** `{}`", &ilo_sys.model));
ctx
.send(CreateReply::default().embed(embed_builder(
"System",
Some(data),
Some(vec![
(
format!("CPU ({}x)", ilo_sys.processor_summary.count),
ilo_sys.processor_summary.model.trim().to_string(),
true
),
("RAM".to_string(), format!("{} GB", ilo_sys.memory.total_system_memory_gb), true),
])
)))
.await?;
Ok(())
}
/// Retrieve the server's IML data
#[poise::command(slash_command)]
async fn logs(ctx: super::PoiseCtx<'_>) -> KonResult<()> {
ctx.defer().await?;
let data: Iml = ilo_data(RedfishEndpoint::LogServices).await?;
let mut log_entries = String::new();
for entry in data.items.iter().rev().take(5) {
let dt = fmt_dt(&entry.created).unwrap_or_else(|| "Unknown".to_string());
log_entries.push_str(&format!("**[{}:{dt}]:** {}\n", entry.severity, entry.message));
}
ctx
.send(CreateReply::default().embed(embed_builder("IML", Some(log_entries), None)))
.await?;
Ok(())
}

View File

@ -1,100 +0,0 @@
use {
kon_tokens::token_path,
poise::{
CreateReply,
serenity_prelude::builder::CreateEmbed
},
serde_json::Value,
std::collections::HashMap,
tokio::join
};
use kon_libs::{
BINARY_PROPERTIES,
HttpClient,
KonResult
};
async fn pms_serverstatus(url: &str) -> KonResult<Vec<(String, Vec<Value>)>> {
let client = HttpClient::new();
let req = client.get(url, "PMS-Status").await?;
let response = req.json::<HashMap<String, Value>>().await?;
let data = response["data"].as_array().unwrap();
let mut servers = Vec::new();
for item in data {
if let Some(title) = item["title"].as_str() {
if let Some(servers_statuses) = item["servers_statuses"]["data"].as_array() {
if !servers_statuses.is_empty() {
servers.push((title.to_owned(), servers_statuses.clone()));
}
}
}
}
Ok(servers)
}
fn process_pms_statuses(servers: Vec<(String, Vec<Value>)>) -> Vec<(String, String, bool)> {
let mut server_map: HashMap<String, Vec<(String, String)>> = HashMap::new();
let id_name_map: HashMap<&str, &str> = [("wotbsg", "ASIA"), ("wowssg", "WoWS (ASIA)"), ("wowseu", "WoWS (EU)")]
.iter()
.cloned()
.collect();
for (title, mapped_servers) in servers {
for server in mapped_servers {
let name = server["name"].as_str().unwrap();
let id = server["id"].as_str().unwrap().split(":").next().unwrap_or("");
let status = match server["availability"].as_str().unwrap() {
"1" => "Online",
"-1" => "Offline",
_ => "Unknown"
};
let name = id_name_map.get(id).unwrap_or(&name);
server_map
.entry(title.clone())
.or_default()
.push((name.to_owned().to_string(), status.to_owned()));
}
}
let mut statuses = Vec::new();
for (title, servers) in server_map {
let servers_str = servers
.iter()
.map(|(name, status)| format!("{name}: {status}"))
.collect::<Vec<String>>()
.join("\n");
statuses.push((title, servers_str, true));
}
statuses
}
/// Query the server statuses
#[poise::command(
slash_command,
install_context = "Guild|User",
interaction_context = "Guild|BotDm|PrivateChannel",
subcommands("wg")
)]
pub async fn status(_: super::PoiseCtx<'_>) -> KonResult<()> { Ok(()) }
/// Retrieve the server statuses from Wargaming
#[poise::command(slash_command)]
async fn wg(ctx: super::PoiseCtx<'_>) -> KonResult<()> {
let pms_asia = token_path().await.wg_pms;
let pms_eu = pms_asia.replace("asia", "eu");
let embed = CreateEmbed::new().color(BINARY_PROPERTIES.embed_color);
let (servers_asia, servers_eu) = join!(pms_serverstatus(&pms_asia), pms_serverstatus(&pms_eu));
let joined_pms_servers = [servers_eu.unwrap(), servers_asia.unwrap()].concat();
let pms_servers = process_pms_statuses(joined_pms_servers.to_vec());
ctx
.send(CreateReply::default().embed(embed.title("Wargaming Server Status").fields(pms_servers)))
.await?;
Ok(())
}

View File

@ -1,107 +0,0 @@
use kon_libs::{
GIT_COMMIT_BRANCH,
GIT_COMMIT_HASH
};
use {
kon_libs::{
BOT_VERSION,
KonResult,
format_duration
},
std::{
fs::File,
io::{
BufRead,
BufReader
},
path::Path,
time::{
Duration,
SystemTime,
UNIX_EPOCH
}
},
sysinfo::System,
uptime_lib::get
};
fn get_os_info() -> String {
let path = Path::new("/etc/os-release");
let mut name = "BoringOS".to_string();
let mut version = "v0.0".to_string();
if let Ok(file) = File::open(path) {
let reader = BufReader::new(file);
let set_value = |s: String| s.split('=').nth(1).unwrap_or_default().trim_matches('"').to_string();
reader.lines().map_while(Result::ok).for_each(|line| match line {
l if l.starts_with("NAME=") => name = set_value(l),
l if l.starts_with("VERSION=") => version = set_value(l),
l if l.starts_with("VERSION_ID=") => version = set_value(l),
_ => {}
});
}
format!("{name} {version}")
}
fn fmt_mem(bytes: u64) -> String {
let units = ["B", "KB", "MB", "GB"];
let mut bytes = bytes as f64;
let mut unit = units[0];
for &u in &units {
if bytes < 1024.0 {
unit = u;
break;
}
bytes /= 1024.0;
}
format!("{bytes:.2} {unit}")
}
/// Retrieve host and bot uptimes
#[poise::command(slash_command, install_context = "Guild|User", interaction_context = "Guild|BotDm|PrivateChannel")]
pub async fn uptime(ctx: super::PoiseCtx<'_>) -> KonResult<()> {
let bot = ctx.http().get_current_user().await.unwrap();
let mut sys = System::new_all();
sys.refresh_all();
// Fetch system's uptime
let sys_uptime = get().unwrap().as_secs();
// Fetch system's processor
let cpu = sys.cpus();
// Fetch system memory usage
let sram = fmt_mem(sys.used_memory());
let sram_total = fmt_mem(sys.total_memory());
// Fetch process memory usage
let pram = match sys.process(sysinfo::get_current_pid().unwrap()) {
Some(proc) => fmt_mem(proc.memory()),
None => String::from("Unavailable")
};
// Fetch bot's process uptime
let curr_pid = sysinfo::get_current_pid().unwrap();
let now = SystemTime::now();
let mut proc_uptime = 0;
if let Some(process) = sys.process(curr_pid) {
let time_started = UNIX_EPOCH + Duration::from_secs(process.start_time());
proc_uptime = now.duration_since(time_started).unwrap().as_secs();
}
let stat_msg = [
format!("**{} v{}** `{GIT_COMMIT_HASH}:{GIT_COMMIT_BRANCH}`", bot.name, BOT_VERSION.as_str()),
format!(">>> System: `{}`", format_duration(sys_uptime)),
format!("Process: `{}`", format_duration(proc_uptime)),
format!("CPU: `{}`", cpu[0].brand()),
format!("RAM: `{pram}` (`{sram}/{sram_total}`)"),
format!("OS: `{}`", get_os_info())
];
ctx.reply(stat_msg.join("\n")).await?;
Ok(())
}

View File

@ -1,2 +0,0 @@
mod dispatch;
pub use dispatch::*;

View File

@ -1,14 +1,22 @@
services:
bot:
container_name: kon
#image: git.toast-server.net/toast/kon:master
#image: 'git.toast-server.net/toast/kon:main'
build: .
env_file:
- .env
restart: unless-stopped
depends_on:
- cache
cache:
container_name: kon-redis
image: redis/redis-stack-server:7.4.0-v1
- db
db:
container_name: kon-database
image: postgres:16.2-alpine3.19@sha256:1d74239810c19ed0dbb317acae40974e673865b2d565a0d369e206159d483957
restart: unless-stopped
ports:
- 37935:6379/tcp
- 37930:5432/tcp
volumes:
- /var/lib/docker/volumes/kon-database:/var/lib/postgresql/data:rw
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}

View File

@ -1,12 +0,0 @@
[package]
name = "kon_libs"
version = "0.1.1"
edition = "2024"
[dependencies]
cargo_toml = { workspace = true }
poise = { workspace = true }
reqwest = { workspace = true }
[features]
production = []

View File

@ -1,26 +0,0 @@
fn main() {
#[cfg(feature = "production")]
{
if let Ok(git_commit_hash) = std::env::var("GIT_COMMIT_HASH") {
println!("cargo:rustc-env=GIT_COMMIT_HASH={}", &git_commit_hash[..7]);
} else {
println!("cargo:warning=GIT_COMMIT_HASH not found");
println!("cargo:rustc-env=GIT_COMMIT_HASH=no_env_set");
}
}
{
let git_branch = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.expect("Command execution failed");
if git_branch.status.success() {
let git_branch = String::from_utf8(git_branch.stdout).expect("Invalid UTF-8 sequence").trim().to_string();
println!("cargo:rustc-env=GIT_COMMIT_BRANCH={}", &git_branch);
} else {
println!("cargo:warning=GIT_COMMIT_BRANCH not found");
println!("cargo:rustc-env=GIT_COMMIT_BRANCH=no_env_set");
}
}
}

View File

@ -1,74 +0,0 @@
use std::sync::LazyLock;
pub struct ConfigMeta {
pub env: String,
pub embed_color: i32,
pub ready_notify: u64,
pub rss_channel: u64,
pub kon_logs: u64,
pub developers: Vec<u64>
}
#[cfg(feature = "production")]
pub static BINARY_PROPERTIES: LazyLock<ConfigMeta> = LazyLock::new(ConfigMeta::new);
#[cfg(not(feature = "production"))]
pub static BINARY_PROPERTIES: LazyLock<ConfigMeta> = LazyLock::new(|| {
ConfigMeta::new()
.env("dev")
.embed_color(0xF1D63C)
.ready_notify(1311282815601741844)
.rss_channel(1311282815601741844)
});
impl ConfigMeta {
fn new() -> Self {
Self {
env: "prod".to_string(),
embed_color: 0x5A99C7,
ready_notify: 1268493237912604672,
rss_channel: 865673694184996888,
kon_logs: 1268493237912604672,
developers: vec![
190407856527376384, // toast.ts
]
}
}
// Scalable functions below;
#[cfg(not(feature = "production"))]
fn env(
mut self,
env: &str
) -> Self {
self.env = env.to_string();
self
}
#[cfg(not(feature = "production"))]
fn embed_color(
mut self,
color: i32
) -> Self {
self.embed_color = color;
self
}
#[cfg(not(feature = "production"))]
fn ready_notify(
mut self,
channel_id: u64
) -> Self {
self.ready_notify = channel_id;
self
}
#[cfg(not(feature = "production"))]
fn rss_channel(
mut self,
channel_id: u64
) -> Self {
self.rss_channel = channel_id;
self
}
}

View File

@ -1 +0,0 @@
pub struct KonData {}

View File

@ -1,50 +0,0 @@
use {
reqwest::{
Client,
Error,
Response
},
std::time::Duration
};
const ERROR_PREFIX: &str = "HTTPClient[Error]:";
pub struct HttpClient(Client);
impl Default for HttpClient {
fn default() -> Self { Self::new() }
}
impl HttpClient {
pub fn new() -> Self { Self(Client::new()) }
pub async fn get(
&self,
url: &str,
ua: &str
) -> Result<Response, Error> {
let response = self
.0
.get(url)
.header(
reqwest::header::USER_AGENT,
format!("Kon ({}-{}) - {ua}/reqwest", crate::BOT_VERSION.as_str(), crate::GIT_COMMIT_HASH)
)
.timeout(Duration::from_secs(30))
.send()
.await;
match response {
Ok(res) => Ok(res),
Err(y) if y.is_timeout() => {
eprintln!("{ERROR_PREFIX} Request timed out for \"{url}\"");
Err(y)
},
Err(y) if y.is_connect() => {
eprintln!("{ERROR_PREFIX} Connection failed for \"{url}\"");
Err(y)
},
Err(y) => Err(y)
}
}
}

View File

@ -1,90 +0,0 @@
mod config;
pub use config::BINARY_PROPERTIES;
mod types;
pub use types::*;
mod data;
pub use data::KonData;
mod http;
pub use http::HttpClient;
use {
cargo_toml::Manifest,
poise::serenity_prelude::UserId,
std::sync::LazyLock
};
#[cfg(feature = "production")]
pub static GIT_COMMIT_HASH: &str = env!("GIT_COMMIT_HASH");
pub static GIT_COMMIT_BRANCH: &str = env!("GIT_COMMIT_BRANCH");
#[cfg(not(feature = "production"))]
pub static GIT_COMMIT_HASH: &str = "devel";
pub static BOT_VERSION: LazyLock<String> = LazyLock::new(|| {
Manifest::from_str(include_str!("../../Cargo.toml"))
.unwrap()
.package
.unwrap()
.version
.unwrap()
});
pub fn mention_dev(ctx: PoiseCtx<'_>) -> Option<String> {
let devs = BINARY_PROPERTIES.developers.clone();
let app_owners = ctx.framework().options().owners.clone();
let mut mentions = Vec::new();
for dev in devs {
if app_owners.contains(&UserId::new(dev)) {
mentions.push(format!("<@{dev}>"));
}
}
if mentions.is_empty() { None } else { Some(mentions.join(", ")) }
}
pub fn format_duration(secs: u64) -> String {
let days = secs / 86400;
let hours = (secs % 86400) / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
let mut formatted_string = String::new();
if days > 0 {
formatted_string.push_str(&format!("{days}d, "));
}
if hours > 0 || days > 0 {
formatted_string.push_str(&format!("{hours}h, "));
}
if minutes > 0 || hours > 0 {
formatted_string.push_str(&format!("{minutes}m, "));
}
formatted_string.push_str(&format!("{seconds}s"));
formatted_string
}
pub fn format_bytes(bytes: u64) -> String {
let units = ["B", "KB", "MB", "GB"];
let mut value = bytes as f64;
let mut unit = units[0];
for &u in &units[1..] {
if value < 1024.0 {
break;
}
value /= 1024.0;
unit = u;
}
if unit == "B" {
format!("{value}{unit}")
} else {
format!("{value:.2}{unit}")
}
}

View File

@ -1,9 +0,0 @@
use {
super::KonData,
std::error::Error
};
pub type KonError = Box<dyn Error + Send + Sync>;
pub type KonResult<T> = Result<T, KonError>;
pub type PoiseCtx<'a> = poise::Context<'a, KonData, KonError>;
pub type PoiseFwCtx<'a> = poise::FrameworkContext<'a, KonData, KonError>;

View File

@ -18,7 +18,6 @@
"branchTopic": "{{{datasource}}}-{{{depName}}}-vulnerability",
"prCreation": "immediate"
},
"enabled": false,
"pinDigests": true,
"ignoreTests": true,
"pruneStaleBranches": true,
@ -26,7 +25,9 @@
"automerge": true,
"automergeType": "pr",
"automergeStrategy": "squash",
"automergeSchedule": ["at any time"],
"automergeSchedule": [
"at any time"
],
"packageRules": [
{
"matchManagers": ["cargo"],

View File

@ -1,10 +0,0 @@
[package]
name = "kon_repo"
version = "0.1.0"
edition = "2024"
[dependencies]
bb8 = { workspace = true }
bb8-redis = { workspace = true }
kon_tokens = { workspace = true }
tokio = { workspace = true }

View File

@ -1,118 +0,0 @@
use kon_tokens::token_path;
use {
bb8_redis::{
RedisConnectionManager,
bb8::Pool,
redis::{
AsyncCommands,
RedisError,
RedisResult,
cmd
}
},
tokio::time::{
Duration,
sleep
}
};
#[derive(Debug)]
pub struct RedisController {
pool: Pool<RedisConnectionManager>
}
impl RedisController {
pub async fn new() -> Result<Self, RedisError> {
let manager = RedisConnectionManager::new(token_path().await.redis_uri.as_str())?;
let pool = Self::create_pool(manager).await;
Ok(Self { pool })
}
async fn create_pool(manager: RedisConnectionManager) -> Pool<RedisConnectionManager> {
let mut backoff = 1;
let redis_err = "Redis[Error]: {{ e }}, retrying in {{ backoff }} seconds";
loop {
match Pool::builder().max_size(20).retry_connection(true).build(manager.clone()).await {
Ok(pool) => match pool.get().await {
Ok(mut conn) => {
let ping: RedisResult<String> = cmd("PING").query_async(&mut *conn).await;
match ping {
Ok(_) => {
println!("Redis[Info]: Successfully connected");
return pool.clone();
},
Err(e) => {
eprintln!(
"{}",
redis_err
.replace("{{ e }}", &e.to_string())
.replace("{{ backoff }}", &backoff.to_string())
);
Self::apply_backoff(&mut backoff).await;
}
}
},
Err(e) => {
eprintln!(
"{}",
redis_err
.replace("{{ e }}", &e.to_string())
.replace("{{ backoff }}", &backoff.to_string())
);
Self::apply_backoff(&mut backoff).await;
}
},
Err(e) => {
eprintln!("Redis[PoolError]: {e}, retrying in {backoff} seconds");
Self::apply_backoff(&mut backoff).await;
}
}
}
}
async fn apply_backoff(backoff: &mut u64) {
sleep(Duration::from_secs(*backoff)).await;
if *backoff < 64 {
*backoff *= 2;
}
}
/// Get a key from the cache
pub async fn get(
&self,
key: &str
) -> RedisResult<Option<String>> {
let mut conn = self.pool.get().await.unwrap();
conn.get(key).await
}
pub async fn del(
&self,
key: &str
) -> RedisResult<()> {
let mut conn = self.pool.get().await.unwrap();
conn.del(key).await
}
/// Set a key with a value in the cache
pub async fn set(
&self,
key: &str,
value: &str
) -> RedisResult<()> {
let mut conn = self.pool.get().await.unwrap();
conn.set(key, value).await
}
/// Set a key with an expiration time in seconds
pub async fn expire(
&self,
key: &str,
seconds: i64
) -> RedisResult<()> {
let mut conn = self.pool.get().await.unwrap();
conn.expire(key, seconds).await
}
}

View File

@ -1,2 +0,0 @@
mod cache;
pub use cache::RedisController;

2
run.sh
View File

@ -1,3 +1,3 @@
#!/bin/bash
clear && cargo fmt && cargo run kon_dev
export $(grep -v '^#' .env | xargs) && cargo run kon_dev

View File

@ -1,2 +0,0 @@
[toolchain]
channel = "nightly"

View File

@ -1,20 +0,0 @@
edition = "2024"
hex_literal_case = "Upper"
binop_separator = "Front"
brace_style = "SameLineWhere"
fn_params_layout = "Vertical"
imports_layout = "Vertical"
imports_granularity = "One"
fn_single_line = true
format_strings = true
max_width = 150
tab_spaces = 2
hard_tabs = false
trailing_comma = "Never"
match_block_trailing_comma = true
reorder_imports = true
reorder_modules = true
reorder_impl_items = true
trailing_semicolon = false
struct_field_align_threshold = 20
condense_wildcard_suffixes = true

265
src/commands/gameserver.rs Normal file
View File

@ -0,0 +1,265 @@
use crate::{
Error,
internals::utils::EMBED_COLOR,
models::gameservers::Gameservers
};
use poise::serenity_prelude::{
futures::{
stream::iter,
future::ready,
Stream,
StreamExt
},
builder::CreateActionRow,
builder::CreateEmbed,
};
use poise::{
CreateReply,
serenity_prelude,
serenity_prelude::ButtonStyle,
ChoiceParameter
};
#[derive(Debug, ChoiceParameter)]
enum GameNames {
#[name = "Minecraft"]
Minecraft
}
/// Manage the game servers for this guild
#[poise::command(
slash_command,
subcommands("add", "remove", "update", "list"),
subcommand_required,
guild_only,
default_member_permissions = "MANAGE_GUILD",
required_permissions = "MANAGE_GUILD" // No clue if this is needed or not. Just leaving it here for now
)]
pub async fn gameserver(_: poise::Context<'_, (), Error>) -> Result<(), Error> {
Ok(())
}
/// Add a game server to the database
#[poise::command(slash_command)]
pub async fn add(
ctx: poise::Context<'_, (), Error>,
#[description = "Server name as shown in-game or friendly name"] server_name: String,
#[description = "Which game is this server running?"] game_name: GameNames,
#[description = "IP address/domain of the server (Include the port if it has one, e.g 127.0.0.1:8080)"] ip_address: String
) -> Result<(), Error> {
let action_row = CreateActionRow::Buttons(vec![
serenity_prelude::CreateButton::new("add-confirm")
.style(ButtonStyle::Success)
.label("Yes"),
serenity_prelude::CreateButton::new("add-cancel")
.style(ButtonStyle::Danger)
.label("No")
]);
let reply = CreateReply::default()
.embed(CreateEmbed::new()
.title("Does this look correct?")
.description(format!("
**Server name:** `{}`
**Game name:** `{}`
**IP Address:** `{}`
", server_name, game_name.name(), ip_address))
.color(EMBED_COLOR)
)
.components(vec![action_row]);
ctx.send(reply).await?;
while let Some(collector) = serenity_prelude::ComponentInteractionCollector::new(ctx)
.guild_id(ctx.guild_id().unwrap())
.author_id(ctx.author().id)
.timeout(std::time::Duration::from_secs(30))
.await
{
if collector.data.custom_id == "add-confirm" {
let result = Gameservers::add_server(
ctx.guild_id().unwrap().into(),
server_name.as_str(),
game_name.name(),
ip_address.as_str()
).await;
let mut msg = collector.message.clone();
match result {
Ok(_) => {
msg.edit(
ctx,
serenity_prelude::EditMessage::new()
.content("*Confirmed, added the server to database*")
.embeds(Vec::new())
.components(Vec::new())
).await?;
},
Err(y) => {
msg.edit(
ctx,
serenity_prelude::EditMessage::new()
.content(format!("*Error adding server to database:\n`{}`*", y))
.embeds(Vec::new())
.components(Vec::new())
).await?;
}
}
} else if collector.data.custom_id == "add-cancel" {
let mut msg = collector.message.clone();
msg.edit(
ctx,
serenity_prelude::EditMessage::new()
.content("*Command cancelled*")
.embeds(Vec::new())
.components(Vec::new())
).await?;
}
}
Ok(())
}
/// Remove a game server from the database
#[poise::command(slash_command)]
pub async fn remove(
ctx: poise::Context<'_, (), Error>,
#[description = "Server name"] #[autocomplete = "ac_server_name"] server_name: String
) -> Result<(), Error> {
let reply = CreateReply::default()
.embed(CreateEmbed::new()
.title("Are you sure you want to remove this server?")
.description(format!("**Server name:** `{}`", server_name))
.color(EMBED_COLOR)
)
.components(vec![
CreateActionRow::Buttons(vec![
serenity_prelude::CreateButton::new("delete-confirm")
.style(ButtonStyle::Success)
.label("Yes"),
serenity_prelude::CreateButton::new("delete-cancel")
.style(ButtonStyle::Danger)
.label("No")
])
]);
ctx.send(reply).await?;
while let Some(collector) = serenity_prelude::ComponentInteractionCollector::new(ctx)
.guild_id(ctx.guild_id().unwrap())
.author_id(ctx.author().id)
.timeout(std::time::Duration::from_secs(30))
.await
{
if collector.data.custom_id == "delete-confirm" {
let result = Gameservers::remove_server(ctx.guild_id().unwrap().into(), server_name.as_str()).await;
let mut msg = collector.message.clone();
match result {
Ok(_) => {
msg.edit(
ctx,
serenity_prelude::EditMessage::new()
.content("*Confirmed, removed the server from database*")
.embeds(Vec::new())
.components(Vec::new())
).await?;
},
Err(y) => {
msg.edit(
ctx,
serenity_prelude::EditMessage::new()
.content(format!("*Error removing server from database:\n`{}`*", y))
.embeds(Vec::new())
.components(Vec::new())
).await?;
}
}
} else if collector.data.custom_id == "delete-cancel" {
let mut msg = collector.message.clone();
msg.edit(
ctx,
serenity_prelude::EditMessage::new()
.content("*Command cancelled*")
.embeds(Vec::new())
.components(Vec::new())
).await?;
}
}
Ok(())
}
/// Update a game server in the database
#[poise::command(slash_command)]
pub async fn update(
ctx: poise::Context<'_, (), Error>,
#[description = "Server name"] #[autocomplete = "ac_server_name"] server_name: String,
#[description = "Game name"] game_name: GameNames,
#[description = "IP address"] ip_address: String
) -> Result<(), Error> {
let result = Gameservers::update_server(
ctx.guild_id().unwrap().into(),
&server_name,
&game_name.name(),
&ip_address
).await;
match result {
Ok(_) => {
ctx.send(CreateReply::default().content("Updated the server in database.")).await?;
},
Err(y) => {
ctx.send(CreateReply::default().content(format!("Error updating the server in database: {:?}", y))).await?;
}
}
Ok(())
}
/// List all the available game servers for this guild
#[poise::command(slash_command)]
pub async fn list(ctx: poise::Context<'_, (), Error>) -> Result<(), Error> {
let servers = Gameservers::list_servers(ctx.guild_id().unwrap().into()).await?;
let mut embed_fields = Vec::new();
for server in servers {
embed_fields.push(
(server.server_name, format!("Game: `{}`\nIP: `{}`", server.game_name, server.ip_address), true)
);
}
ctx.send(CreateReply::default()
.embed(CreateEmbed::new()
.title("List of registered gameservers")
.fields(embed_fields)
.color(EMBED_COLOR)
)
).await?;
Ok(())
}
pub async fn ac_server_name<'a>(
ctx: poise::Context<'_, (), Error>,
partial: &'a str
) -> impl Stream<Item = String> + 'a {
let result = Gameservers::get_server_names(ctx.guild_id().unwrap().into()).await;
let names = match result {
Ok(names_vector) => names_vector,
Err(y) => {
println!("Error retrieving server names: {:?}", y);
Vec::new()
}
};
iter(names)
.filter(move |server_name| ready(server_name.starts_with(partial)))
.map(|server_name| server_name.to_string())
}

5
src/commands/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod ping;
pub mod status;
pub mod uptime;
pub mod gameserver;
pub mod moderation;

203
src/commands/moderation.rs Normal file
View File

@ -0,0 +1,203 @@
use crate::{
Error,
internals::utils::capitalize_first,
models::moderation_events::{
Moderations,
ActionTypes
}
};
use poise::CreateReply;
use poise::serenity_prelude::Member;
use parse_duration::parse;
use std::time::SystemTime;
static FALLBACK_REASON: &str = "Reason unknown";
fn duration2epoch(duration: &str) -> Result<i64, Error> {
match parse(duration) {
Ok(dur) => {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|_| Error::from("System time before Unix Epoch"))?;
Ok((now + dur).as_secs() as i64)
}
Err(_) => Err(Error::from("Invalid duration format"))
}
}
/// Subcommands collection for /case command
#[poise::command(
slash_command,
guild_only,
subcommands("update"),
default_member_permissions = "KICK_MEMBERS | BAN_MEMBERS | MODERATE_MEMBERS"
)]
pub async fn case(_: poise::Context<'_, (), Error>) -> Result<(), Error> {
Ok(())
}
/// Update a case with new reason
#[poise::command(slash_command, guild_only)]
pub async fn update(
ctx: poise::Context<'_, (), Error>,
#[description = "Case ID to update"] case_id: i32,
#[description = "New reason for the case"] reason: String
) -> Result<(), Error> {
match Moderations::update_case(
i64::from(ctx.guild_id().unwrap()),
case_id,
false,
Some(reason.clone())
).await {
Ok(_) => ctx.send(CreateReply::default().content(format!("Case #{} updated with new reason:\n`{}`", case_id, reason))).await?,
Err(e) => ctx.send(CreateReply::default().content(format!("Error updating case ID: {}\nError: {}", case_id, e))).await?
};
Ok(())
}
/// Kick a member
#[poise::command(
slash_command,
guild_only,
default_member_permissions = "KICK_MEMBERS",
required_bot_permissions = "KICK_MEMBERS"
)]
pub async fn kick(
ctx: poise::Context<'_, (), Error>,
#[description = "Member to be kicked"] member: Member,
#[description = "Reason for the kick"] reason: Option<String>
) -> Result<(), Error> {
let reason = reason.unwrap_or(FALLBACK_REASON.to_string());
let case = Moderations::create_case(
i64::from(ctx.guild_id().unwrap()),
ActionTypes::Kick,
false,
i64::from(member.user.id),
member.user.tag(),
reason.clone(),
i64::from(ctx.author().id),
ctx.author().tag(),
ctx.created_at().timestamp(),
None
).await?;
Moderations::generate_modlog(case.clone(), &ctx.http(), ctx.channel_id().into()).await?;
member.kick_with_reason(&ctx.http(), &reason).await?;
ctx.send(CreateReply::default().content(format!("Member: {}\nReason: `{}`\nType: {}\nModerator: {}", member.user.tag(), reason, capitalize_first(case.action_type.as_str()), ctx.author().tag()))).await?;
Ok(())
}
/// Ban a member
#[poise::command(
slash_command,
guild_only,
default_member_permissions = "BAN_MEMBERS",
required_bot_permissions = "BAN_MEMBERS"
)]
pub async fn ban(
ctx: poise::Context<'_, (), Error>,
#[description = "Member to be banned"] member: Member,
#[description = "Reason for the ban"] reason: Option<String>,
#[description = "Ban duration"] duration: Option<String>
) -> Result<(), Error> {
let reason = reason.unwrap_or(FALLBACK_REASON.to_string());
let duration = match duration {
Some(d) => Some(duration2epoch(&d)?),
None => None
};
let is_case_active = duration.is_some();
let case = Moderations::create_case(
i64::from(ctx.guild_id().unwrap()),
ActionTypes::Ban,
is_case_active,
i64::from(member.user.id),
member.user.tag(),
reason.clone(),
i64::from(ctx.author().id),
ctx.author().tag(),
ctx.created_at().timestamp(),
duration
).await?;
Moderations::generate_modlog(case.clone(), &ctx.http(), ctx.channel_id().into()).await?;
member.ban_with_reason(&ctx.http(), 0, &reason).await?;
ctx.send(CreateReply::default().content(format!("Member: {}\nReason: `{}`\nType: {}\nModerator: {}\nDuration: `{}`", member.user.tag(), reason, capitalize_first(case.action_type.as_str()), ctx.author().tag(), duration.unwrap()))).await?;
Ok(())
}
/// Timeout a member
#[poise::command(
slash_command,
guild_only,
default_member_permissions = "MODERATE_MEMBERS",
required_bot_permissions = "MODERATE_MEMBERS"
)]
pub async fn mute(
ctx: poise::Context<'_, (), Error>,
#[description = "Member to be muted"] mut member: Member,
#[description = "Mute duration"] duration: String,
#[description = "Reason for the mute"] reason: Option<String>
) -> Result<(), Error> {
let reason = reason.unwrap_or(FALLBACK_REASON.to_string());
let duration = Some(duration2epoch(&duration)?);
let is_case_active = duration.is_some();
let case = Moderations::create_case(
i64::from(ctx.guild_id().unwrap()),
ActionTypes::Mute,
is_case_active,
i64::from(member.user.id),
member.user.tag(),
reason.clone(),
i64::from(ctx.author().id),
ctx.author().tag(),
ctx.created_at().timestamp(),
duration
).await?;
println!("case.duration: {}", case.duration.unwrap().to_string().as_str());
let mute_time = poise::serenity_prelude::Timestamp::from_unix_timestamp(case.duration.unwrap()).expect("Failed to format timestamp");
member.disable_communication_until_datetime(&ctx.http(), mute_time).await?;
ctx.send(CreateReply::default().content(format!("Member: {}\nReason: `{}`\nType: {}\nModerator: {}\nDuration: `{}`", member.user.tag(), reason, capitalize_first(case.action_type.as_str()), ctx.author().tag(), mute_time))).await?;
Ok(())
}
/// Warn a member
#[poise::command(
slash_command,
guild_only,
default_member_permissions = "MODERATE_MEMBERS",
required_bot_permissions = "MODERATE_MEMBERS"
)]
pub async fn warn(
ctx: poise::Context<'_, (), Error>,
#[description = "Member to be warned"] member: Member,
#[description = "Reason for the warn"] reason: Option<String>
) -> Result<(), Error> {
let reason = reason.unwrap_or(FALLBACK_REASON.to_string());
let case = Moderations::create_case(
i64::from(ctx.guild_id().unwrap()),
ActionTypes::Warn,
false,
i64::from(member.user.id),
member.user.tag(),
reason.clone(),
i64::from(ctx.author().id),
ctx.author().tag(),
ctx.created_at().timestamp(),
None
).await?;
Moderations::generate_modlog(case.clone(), &ctx.http(), ctx.channel_id().into()).await?;
ctx.send(CreateReply::default().content(format!("Member: {}\nReason: `{}`\nType: {}\nModerator: {}", member.user.tag(), reason, capitalize_first(case.action_type.as_str()), ctx.author().tag()))).await?;
Ok(())
}

8
src/commands/ping.rs Normal file
View File

@ -0,0 +1,8 @@
use crate::Error;
/// Check if the bot is alive
#[poise::command(slash_command)]
pub async fn ping(ctx: poise::Context<'_, (), Error>) -> Result<(), Error> {
ctx.reply(format!("Powong! `{:?}`", ctx.ping().await)).await?;
Ok(())
}

164
src/commands/status.rs Normal file
View File

@ -0,0 +1,164 @@
use crate::{
Error,
models::gameservers::Gameservers,
commands::gameserver::ac_server_name,
internals::utils::EMBED_COLOR,
internals::http::HttpClient,
internals::utils::token_path
};
use std::collections::HashMap;
use tokio::join;
use poise::CreateReply;
use poise::serenity_prelude::builder::CreateEmbed;
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize)]
struct MinecraftQueryData {
motd: Option<MinecraftMotd>,
players: Option<MinecraftPlayers>,
version: Option<String>,
online: bool
}
#[derive(Deserialize)]
struct MinecraftMotd {
clean: Vec<String>
}
#[derive(Deserialize, Clone, Copy)]
struct MinecraftPlayers {
online: i32,
max: i32
}
async fn pms_serverstatus(url: &str) -> Result<Vec<(String, Vec<Value>)>, Error> {
let client = HttpClient::new();
let req = client.get(url).await?;
let response = req.json::<HashMap<String, Value>>().await?;
let data = response["data"].as_array().unwrap();
let mut servers = Vec::new();
for item in data {
if let Some(title) = item["title"].as_str() {
if let Some(servers_statuses) = item["servers_statuses"]["data"].as_array() {
if !servers_statuses.is_empty() {
servers.push((title.to_owned(), servers_statuses.clone()));
}
}
}
}
Ok(servers)
}
fn process_pms_statuses(servers: Vec<(String, Vec<Value>)>) -> Vec<(String, String, bool)> {
let mut server_map: HashMap<String, Vec<(String, String)>> = HashMap::new();
let id_name_map: HashMap<&str, &str> = [
("wotbsg", "ASIA"),
("wowssg", "WoWS (ASIA)"),
("wowseu", "WoWS (EU)")
].iter().cloned().collect();
for (title, mapped_servers) in servers {
for server in mapped_servers {
let name = server["name"].as_str().unwrap();
let id = server["id"].as_str().unwrap().split(":").next().unwrap_or("");
let status = match server["availability"].as_str().unwrap() {
"1" => "Online",
"-1" => "Offline",
_ => "Unknown"
};
let name = id_name_map.get(id).unwrap_or(&name);
server_map.entry(title.clone()).or_insert_with(Vec::new).push((name.to_owned().to_string(), status.to_owned()));
}
}
let mut statuses = Vec::new();
for (title, servers) in server_map {
let servers_str = servers.iter().map(|(name, status)| format!("{}: {}", name, status)).collect::<Vec<String>>().join("\n");
statuses.push((title, servers_str, true));
}
statuses
}
async fn gs_query_minecraft(server_ip: &str) -> Result<MinecraftQueryData, Error> {
let client = HttpClient::new();
let req = client.get(&format!("https://api.mcsrvstat.us/2/{}", server_ip)).await?;
if req.status().is_success() {
let data: MinecraftQueryData = req.json().await?;
Ok(data)
} else if req.status().is_server_error() {
Err(Error::from("Webserver returned a 5xx error."))
} else {
Err(Error::from("Failed to query the server."))
}
}
/// Query the server statuses
#[poise::command(slash_command, subcommands("wg", "gs"), subcommand_required)]
pub async fn status(_: poise::Context<'_, (), Error>) -> Result<(), Error> {
Ok(())
}
/// Retrieve the server statuses from Wargaming
#[poise::command(slash_command)]
pub async fn wg(ctx: poise::Context<'_, (), Error>) -> Result<(), Error> {
let pms_asia = token_path().await.wg_pms;
let pms_eu = pms_asia.replace("asia", "eu");
let embed = CreateEmbed::new().color(EMBED_COLOR);
let (servers_asia, servers_eu) = join!(pms_serverstatus(&pms_asia), pms_serverstatus(&pms_eu));
let joined_pms_servers = [servers_eu.unwrap(), servers_asia.unwrap()].concat();
let pms_servers = process_pms_statuses(joined_pms_servers.to_vec());
ctx.send(CreateReply::default().embed(embed.title("Wargaming Server Status").fields(pms_servers))).await?;
Ok(())
}
/// Retrieve the given server data from gameservers DB
#[poise::command(slash_command, guild_only)]
pub async fn gs(
ctx: poise::Context<'_, (), Error>,
#[description = "Server name"] #[autocomplete = "ac_server_name"] server_name: String
) -> Result<(), Error> {
let server_data = Gameservers::get_server_data(ctx.guild_id().unwrap().into(), &server_name).await?;
// Extract values from a Vec above
let game_name = &server_data[1];
let ip_address = &server_data[2];
match game_name.as_str() {
"Minecraft" => {
let result = gs_query_minecraft(ip_address).await?;
let embed = CreateEmbed::new().color(EMBED_COLOR);
if result.online {
let mut embed_fields = Vec::new();
embed_fields.push(("Server IP".to_owned(), ip_address.to_owned(), true));
embed_fields.push((format!("\u{200b}"), format!("\u{200b}"), true));
embed_fields.push(("MOTD".to_owned(), format!("{}", result.motd.unwrap().clean[0]), true));
embed_fields.push(("Players".to_owned(), format!("**{}**/**{}**", result.players.unwrap().online, result.players.clone().unwrap().max), true));
embed_fields.push(("Version".to_owned(), result.version.unwrap(), true));
ctx.send(CreateReply::default()
.embed(embed
.title(server_name)
.fields(embed_fields)
)
).await?;
} else {
ctx.send(CreateReply::default()
.content(format!("**{}** (`{}`) is currently offline or unreachable.", server_name, ip_address))
).await?;
}
},
_ => {}
}
Ok(())
}

45
src/commands/uptime.rs Normal file
View File

@ -0,0 +1,45 @@
use crate::{
Error,
internals::utils::{
format_duration,
concat_message,
BOT_VERSION
}
};
use sysinfo::System;
use uptime_lib::get;
use std::time::{
Duration,
SystemTime,
UNIX_EPOCH
};
/// Retrieve host and bot uptimes
#[poise::command(slash_command)]
pub async fn uptime(ctx: poise::Context<'_, (), Error>) -> Result<(), Error> {
let _bot = ctx.http().get_current_user().await.unwrap();
let mut sys = System::new_all();
sys.refresh_all();
// Fetch system's uptime
let sys_uptime = get().unwrap().as_secs();
// Fetch bot's process uptime
let curr_pid = sysinfo::get_current_pid().unwrap();
let now = SystemTime::now();
let mut proc_uptime = 0;
if let Some(process) = sys.process(curr_pid) {
let time_started = UNIX_EPOCH + Duration::from_secs(process.start_time());
proc_uptime = now.duration_since(time_started).unwrap().as_secs();
}
let stat_msg = vec![
format!("**{} {}**", _bot.name, BOT_VERSION.as_str()),
format!(">>> System: `{}`", format_duration(sys_uptime)),
format!("Process: `{}`", format_duration(proc_uptime))
];
ctx.reply(concat_message(stat_msg)).await?;
Ok(())
}

View File

@ -0,0 +1,64 @@
use crate::internals;
use poise::serenity_prelude::prelude::TypeMapKey;
use tokio_postgres::{Client, NoTls, Error};
pub struct DatabaseController {
pub client: Client
}
impl TypeMapKey for DatabaseController {
type Value = DatabaseController;
}
impl DatabaseController {
pub async fn new() -> Result<DatabaseController, Error> {
let (client, connection) = tokio_postgres::connect(&internals::utils::token_path().await.postgres_uri, NoTls).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("Connection error: {}", e);
}
});
// Gameservers
client.batch_execute("
CREATE TABLE IF NOT EXISTS gameservers (
server_name VARCHAR(255) NOT NULL,
game_name VARCHAR(255) NOT NULL,
guild_owner BIGINT NOT NULL,
ip_address VARCHAR(255) NOT NULL,
PRIMARY KEY (server_name, guild_owner)
);
").await?;
// Guild Case IDs
client.batch_execute("
CREATE TABLE IF NOT EXISTS guild_case_ids (
guild_id BIGINT NOT NULL,
max_case_id INT NOT NULL DEFAULT 0,
PRIMARY KEY (guild_id)
);
").await?;
// ModerationEvents
client.batch_execute("
CREATE TABLE IF NOT EXISTS moderation_events (
guild_id BIGINT NOT NULL,
case_id INT NOT NULL,
action_type VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
user_id BIGINT NOT NULL,
user_tag VARCHAR(255) NOT NULL,
reason VARCHAR(1024) NOT NULL,
moderator_id BIGINT NOT NULL,
moderator_tag VARCHAR(255) NOT NULL,
time_created BIGINT NOT NULL,
duration BIGINT,
PRIMARY KEY (guild_id, case_id)
);
").await?;
Ok(DatabaseController { client })
}
}

2
src/controllers/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod database;
pub mod timers;

95
src/controllers/timers.rs Normal file
View File

@ -0,0 +1,95 @@
use crate::{
Error,
models::moderation_events::{
Moderations,
ActionTypes
}
};
use std::time::SystemTime;
use poise::serenity_prelude::{
Context,
model::{
user::CurrentUser,
id::{
UserId,
GuildId
}
},
};
use tokio::time::{
interval,
Duration
};
fn timer_failed(name: &str) -> String {
format!("Failed to start timer for {}", name)
}
pub async fn start_timers(discord_: &Context, bot_: CurrentUser) -> Result<(), Error> {
let ctx_clone = discord_.clone();
tokio::spawn(async move {
check_modlog_cases(&ctx_clone, bot_).await.expect(&timer_failed("moderation events"))
});
Ok(())
}
async fn check_modlog_cases(discord_: &Context, bot_: CurrentUser) -> Result<(), Error> {
let mut interval = interval(Duration::from_secs(6));
loop {
interval.tick().await;
let events = Moderations::get_active_events().await?;
for event in events {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|_| Error::from("System time before Unix Epoch"))?;
let check_action_type = match event.action_type {
ActionTypes::Ban => ActionTypes::Unban,
ActionTypes::Mute => ActionTypes::Unmute,
_ => continue // Skip if not a timed action
};
if let Some(duration) = event.duration {
let duration = Duration::from_secs(duration as u64);
if now > duration {
Moderations::update_case(
event.guild_id,
event.case_id,
false,
None
).await?;
Moderations::generate_modlog(Moderations::create_case(
event.guild_id,
check_action_type,
false,
event.user_id,
event.user_tag.clone(),
format!("Duration for Case #{} has expired", event.case_id),
bot_.id.into(),
bot_.tag(),
SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs() as i64,
None
).await?, &discord_.http, 865673694184996888).await?;
match check_action_type {
ActionTypes::Unban => {
let guild_id = GuildId::new(event.guild_id as u64);
let user_id = UserId::new(event.user_id as u64);
discord_.http.remove_ban(guild_id, user_id, Some(format!("Duration for Case #{} has expired", event.case_id).as_str())).await?;
},
_ => {}
}
let guild_id = GuildId::new(event.guild_id as u64);
let cached_guild_data = discord_.cache.guild(guild_id);
println!("ModerationTimer[CaseExpired]: {}:#{}:{}:{}", cached_guild_data.unwrap().name.to_owned(), event.case_id, event.user_tag, event.reason)
}
}
}
}
}

View File

@ -1,29 +0,0 @@
use {
kon_libs::{
KonData,
KonError,
mention_dev
},
poise::FrameworkError
};
pub async fn fw_errors(error: FrameworkError<'_, KonData, KonError>) {
match error {
poise::FrameworkError::Command { error, ctx, .. } => {
println!("PoiseCommandError({}): {error}", ctx.command().qualified_name);
ctx
.reply(format!(
"Encountered an error during command execution, ask {} to check console for more details!",
mention_dev(ctx).unwrap_or_default()
))
.await
.expect("Error sending message");
},
poise::FrameworkError::EventHandler { error, event, .. } => println!("PoiseEventHandlerError({}): {error}", event.snake_case_name()),
poise::FrameworkError::UnknownInteraction { interaction, .. } => println!(
"PoiseUnknownInteractionError: {} tried to execute an unknown interaction ({})",
interaction.user.name, interaction.data.name
),
other => println!("PoiseOtherError: {other}")
}
}

30
src/internals/http.rs Normal file
View File

@ -0,0 +1,30 @@
use std::sync::Arc;
use once_cell::sync::Lazy;
use reqwest::{
Client,
header::USER_AGENT
};
static CUSTOM_USER_AGENT: Lazy<String> = Lazy::new(||
format!("Kon/{}/Rust", super::utils::BOT_VERSION.as_str())
);
pub struct HttpClient {
client: Arc<Client>
}
impl HttpClient {
pub fn new() -> Self {
Self {
client: Arc::new(Client::new())
}
}
pub async fn get(&self, url: &str) -> Result<reqwest::Response, reqwest::Error> {
let req = self.client.get(url)
.header(USER_AGENT, CUSTOM_USER_AGENT.as_str())
.send()
.await?;
Ok(req)
}
}

3
src/internals/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod utils;
pub mod http;
pub mod tsclient;

19
src/internals/tsclient.rs Normal file
View File

@ -0,0 +1,19 @@
use tokenservice_client::{TokenService, TokenServiceApi};
pub struct TSClient {
client: TokenService
}
impl TSClient {
pub fn new() -> Self {
let args: Vec<String> = std::env::args().collect();
let service = if args.len() > 1 { args[1].as_str() } else { "kon" };
TSClient {
client: TokenService::new(service)
}
}
pub async fn get(&self) -> Result<TokenServiceApi, Box<dyn std::error::Error>> {
let api = self.client.connect().await.unwrap();
Ok(api)
}
}

47
src/internals/utils.rs Normal file
View File

@ -0,0 +1,47 @@
use once_cell::sync::Lazy;
use tokenservice_client::TokenServiceApi;
pub static EMBED_COLOR: i32 = 0x5a99c7;
pub static BOT_VERSION: Lazy<String> = Lazy::new(|| {
let cargo_version = cargo_toml::Manifest::from_path("Cargo.toml").unwrap().package.unwrap().version.unwrap();
format!("v{}", cargo_version)
});
pub async fn token_path() -> TokenServiceApi {
let client = super::tsclient::TSClient::new().get().await.unwrap();
client
}
pub fn concat_message(messages: Vec<String>) -> String {
messages.join("\n")
}
pub fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
}
}
pub fn format_duration(secs: u64) -> String {
let days = secs / 86400;
let hours = (secs % 86400) / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
let mut formatted_string = String::new();
if days > 0 {
formatted_string.push_str(&format!("{}d, ", days));
}
if hours > 0 || days > 0 {
formatted_string.push_str(&format!("{}h, ", hours));
}
if minutes > 0 || hours > 0 {
formatted_string.push_str(&format!("{}m, ", minutes));
}
formatted_string.push_str(&format!("{}s", seconds));
formatted_string
}

View File

@ -1,113 +1,111 @@
mod errors;
mod shutdown;
// https://cdn.toast-server.net/RustFSHiearchy.png
// Using the new filesystem hierarchy
mod commands;
mod controllers;
mod models;
mod internals;
use {
kon_cmds::register_cmds,
kon_libs::{
BINARY_PROPERTIES,
BOT_VERSION,
GIT_COMMIT_BRANCH,
GIT_COMMIT_HASH,
KonData,
KonResult
use poise::serenity_prelude::{
builder::{
CreateMessage,
CreateEmbed,
CreateEmbedAuthor
},
kon_tokens::token_path,
poise::serenity_prelude::{
ChannelId,
ClientBuilder,
Context,
GatewayIntents,
Ready,
builder::{
CreateEmbed,
CreateEmbedAuthor,
CreateMessage
}
},
std::borrow::Cow
Context,
Ready,
ClientBuilder,
ChannelId,
Command,
GatewayIntents
};
type Error = Box<dyn std::error::Error + Send + Sync>;
static BOT_READY_NOTIFY: u64 = 865673694184996888;
static CAN_DEPLOY_COMMANDS: bool = false;
async fn on_ready(
ctx: &Context,
ready: &Ready
) -> KonResult<KonData> {
#[cfg(not(feature = "production"))]
{
println!("Event[Ready][Notice]: Detected a non-production environment!");
let gateway = ctx.http.get_bot_gateway().await?;
let session = gateway.session_start_limit;
println!("Event[Ready][Notice]: Session limit: {}/{}", session.remaining, session.total);
}
ready: &Ready,
framework: &poise::Framework<(), Error>
) -> Result<(), Error> {
println!("Connected to API as {}", ready.user.name);
println!("Event[Ready]: Build version: {} ({GIT_COMMIT_HASH}:{GIT_COMMIT_BRANCH})", *BOT_VERSION);
println!("Event[Ready]: Connected to API as {}", ready.user.name);
controllers::timers::start_timers(&ctx, ready.user.to_owned()).await.expect("Failed to start timers");
let message = CreateMessage::new();
let ready_embed = CreateEmbed::new()
.color(BINARY_PROPERTIES.embed_color)
.color(internals::utils::EMBED_COLOR)
.thumbnail(ready.user.avatar_url().unwrap_or_default())
.author(CreateEmbedAuthor::new(format!("{} is ready!", ready.user.name)));
.author(CreateEmbedAuthor::new(format!("{} is ready!", ready.user.name)).clone());
ChannelId::new(BINARY_PROPERTIES.ready_notify)
.send_message(&ctx.http, message.add_embed(ready_embed))
.await?;
ChannelId::new(BOT_READY_NOTIFY).send_message(&ctx.http, message.add_embed(ready_embed)).await?;
Ok(KonData {})
if CAN_DEPLOY_COMMANDS {
let builder = poise::builtins::create_application_commands(&framework.options().commands);
let commands = Command::set_global_commands(&ctx.http, builder).await;
match commands {
Ok(cmdmap) => {
let command_box: Vec<_> = cmdmap.iter().map(|cmd| cmd.name.clone()).collect();
println!("Registered commands globally: {}", command_box.join("\n- "));
},
Err(why) => println!("Error registering commands: {:?}", why)
}
}
Ok(())
}
#[tokio::main]
async fn main() {
let prefix = if BINARY_PROPERTIES.env.contains("dev") {
Some(Cow::Borrowed("kon!"))
} else {
Some(Cow::Borrowed("k!"))
};
let db = controllers::database::DatabaseController::new().await.expect("Failed to connect to database");
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
commands: register_cmds(),
prefix_options: poise::PrefixFrameworkOptions {
prefix,
mention_as_prefix: false,
case_insensitive_commands: true,
ignore_bots: true,
ignore_thread_creation: true,
..Default::default()
},
pre_command: |ctx| {
Box::pin(async move {
let get_guild_name = match ctx.guild() {
Some(guild) => guild.name.clone(),
None => String::from("DM/User-App")
};
println!("Discord[{get_guild_name}]: {} ran /{}", ctx.author().name, ctx.command().qualified_name);
})
},
on_error: |error| Box::pin(async move { errors::fw_errors(error).await }),
commands: vec![
commands::ping::ping(),
commands::uptime::uptime(),
commands::status::status(),
commands::gameserver::gameserver(),
// Separator here to make it easier to read and update moderation stuff below
commands::moderation::case(),
commands::moderation::update(),
commands::moderation::ban(),
commands::moderation::kick(),
commands::moderation::mute(),
commands::moderation::warn(),
],
pre_command: |ctx| Box::pin(async move {
let get_guild_name = match ctx.guild() {
Some(guild) => guild.name.clone(),
None => String::from("DM")
};
println!("[{}] {} ran /{}", get_guild_name, ctx.author().name, ctx.command().qualified_name)
}),
on_error: |error| Box::pin(async move {
match error {
poise::FrameworkError::Command { error, ctx, .. } => {
println!("PoiseCommandError({}): {}", ctx.command().qualified_name, error);
}
other => println!("PoiseOtherError: {}", other)
}
}),
initialize_owners: true,
..Default::default()
})
.setup(|ctx, ready, _| Box::pin(on_ready(ctx, ready)))
.setup(|ctx, ready, framework| Box::pin(on_ready(ctx, ready, framework)))
.build();
let mut client = ClientBuilder::new(
token_path().await.main,
GatewayIntents::GUILDS | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT
)
.framework(framework)
.await
.expect("Error creating client");
let mut client = ClientBuilder::new(internals::utils::token_path().await.main, GatewayIntents::GUILDS)
.framework(framework)
.await.expect("Error creating client");
let shard_manager = client.shard_manager.clone();
tokio::spawn(async move {
shutdown::gracefully_shutdown().await;
shard_manager.shutdown_all().await;
});
{
let mut data = client.data.write().await;
data.insert::<controllers::database::DatabaseController>(db);
}
if let Err(why) = client.start().await {
println!("Error starting client: {why:#?}");
println!("Client error: {:?}", why);
}
}

103
src/models/gameservers.rs Normal file
View File

@ -0,0 +1,103 @@
use crate::controllers::database::DatabaseController;
pub struct Gameservers {
pub server_name: String,
pub game_name: String,
pub guild_owner: i64,
pub ip_address: String
}
impl Gameservers {
pub async fn list_servers(guild_id: u64) -> Result<Vec<Self>, tokio_postgres::Error> {
let client = DatabaseController::new().await?.client;
let rows = client.query("
SELECT * FROM gameservers
WHERE guild_owner = $1
", &[&(guild_id as i64)]).await?;
let mut servers = Vec::new();
for row in rows {
servers.push(Self {
server_name: row.get("server_name"),
game_name: row.get("game_name"),
guild_owner: row.get("guild_owner"),
ip_address: row.get("ip_address")
});
}
Ok(servers)
}
pub async fn add_server(
guild_id: u64,
server_name: &str,
game_name: &str,
ip_address: &str
) -> Result<(), tokio_postgres::Error> {
let client = DatabaseController::new().await?.client;
client.execute("
INSERT INTO gameservers (server_name, game_name, guild_owner, ip_address)
VALUES ($1, $2, $3, $4)
", &[&server_name, &game_name, &(guild_id as i64), &ip_address]).await?;
Ok(())
}
pub async fn remove_server(guild_id: u64, server_name: &str) -> Result<(), tokio_postgres::Error> {
let client = DatabaseController::new().await?.client;
client.execute("
DELETE FROM gameservers
WHERE guild_owner = $1 AND server_name = $2
", &[&(guild_id as i64), &server_name]).await?;
Ok(())
}
pub async fn update_server(
guild_id: u64,
server_name: &str,
game_name: &str,
ip_address: &str
) -> Result<(), tokio_postgres::Error> {
let client = DatabaseController::new().await?.client;
client.execute("
UPDATE gameservers
SET game_name = $1, ip_address = $2
WHERE guild_owner = $3 AND server_name = $4
", &[&game_name, &ip_address, &(guild_id as i64), &server_name]).await?;
Ok(())
}
pub async fn get_server_names(guild_id: u64) -> Result<Vec<String>, tokio_postgres::Error> {
let client = DatabaseController::new().await?.client;
let rows = client.query("
SELECT server_name FROM gameservers
WHERE guild_owner = $1
", &[&(guild_id as i64)]).await?;
let mut servers = Vec::new();
for row in rows {
servers.push(row.get("server_name"));
}
Ok(servers)
}
pub async fn get_server_data(guild_id: u64, server_name: &str) -> Result<Vec<String>, tokio_postgres::Error> {
let client = DatabaseController::new().await?.client;
let rows = client.query("
SELECT * FROM gameservers
WHERE guild_owner = $1 AND server_name = $2
", &[&(guild_id as i64), &server_name]).await?;
let mut server = Vec::new();
for row in rows {
server.push(row.get("server_name"));
server.push(row.get("game_name"));
server.push(row.get("ip_address"))
}
Ok(server)
}
}

2
src/models/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod gameservers;
pub mod moderation_events;

View File

@ -0,0 +1,215 @@
use crate::{
controllers::database::DatabaseController,
internals::utils::{
EMBED_COLOR,
capitalize_first
}
};
use poise::serenity_prelude::{
Http,
Error,
Timestamp,
ChannelId,
CreateMessage,
CreateEmbed
};
#[derive(Clone)]
pub struct Moderations {
pub guild_id: i64,
pub case_id: i32,
pub action_type: ActionTypes,
pub is_active: bool,
pub user_id: i64,
pub user_tag: String,
pub reason: String,
pub moderator_id: i64,
pub moderator_tag: String,
pub time_created: i64,
pub duration: Option<i64>
}
#[derive(Clone, Copy)]
pub enum ActionTypes {
Ban,
Kick,
Mute,
Warn,
Unban,
Unmute,
Unknown
}
impl ActionTypes {
pub fn as_str(&self) -> &'static str {
match *self {
ActionTypes::Ban => "ban",
ActionTypes::Kick => "kick",
ActionTypes::Mute => "mute",
ActionTypes::Warn => "warn",
ActionTypes::Unban => "unban",
ActionTypes::Unmute => "unmute",
ActionTypes::Unknown => "unknown"
}
}
}
impl Moderations {
pub async fn get_active_events() -> Result<Vec<Moderations>, tokio_postgres::Error> {
let _db = DatabaseController::new().await?.client;
_db.execute("BEGIN", &[]).await.expect("Failed to start transaction!");
let stmt = _db.prepare("
SELECT * FROM moderation_events
WHERE is_active = true
ORDER BY duration DESC, time_created DESC;
").await?;
_db.execute("COMMIT", &[]).await.expect("Failed to commit transaction!");
let rows = _db.query(&stmt, &[]).await?;
let mut moderations = Vec::new();
for row in rows {
moderations.push(Moderations {
guild_id: row.get("guild_id"),
case_id: row.get("case_id"),
action_type: match row.get::<_, &str>("action_type") {
"ban" => ActionTypes::Ban,
"kick" => ActionTypes::Kick,
"mute" => ActionTypes::Mute,
"warn" => ActionTypes::Warn,
"unban" => ActionTypes::Unban,
"unmute" => ActionTypes::Unmute,
_ => ActionTypes::Unknown
},
is_active: row.get("is_active"),
user_id: row.get("user_id"),
user_tag: row.get("user_tag"),
reason: row.get("reason"),
moderator_id: row.get("moderator_id"),
moderator_tag: row.get("moderator_tag"),
time_created: row.get("time_created"),
duration: row.get("duration")
});
}
Ok(moderations)
}
pub async fn generate_modlog(case: Moderations, http: &Http, channel_id: u64) -> Result<(), Error> {
let time_created_formatted = Timestamp::from_unix_timestamp(case.time_created).expect(" Failed to format timestamp!");
let modlog_embed = CreateEmbed::default()
.color(EMBED_COLOR)
.title(format!("{} • Case #{}", capitalize_first(case.action_type.as_str()), case.case_id))
.fields(vec![
("User", format!("{}\n<@{}>", case.user_tag, case.user_id), true),
("Moderator", format!("{}\n<@{}>", case.moderator_tag, case.moderator_id), true),
("\u{200B}", "\u{200B}".to_string(), true),
("Reason", format!("`{}`", case.reason), false)
])
.timestamp(time_created_formatted);
ChannelId::new(channel_id).send_message(http, CreateMessage::new().embed(modlog_embed)).await?;
Ok(())
}
pub async fn create_case(
guild_id: i64,
action_type: ActionTypes,
is_active: bool,
user_id: i64,
user_tag: String,
reason: String,
moderator_id: i64,
moderator_tag: String,
time_created: i64,
duration: Option<i64>
) -> Result<Moderations, tokio_postgres::Error> {
let _db = DatabaseController::new().await?.client;
// Get the current max case_id for the guild
let stmt = _db.prepare("
SELECT max_case_id FROM guild_case_ids WHERE guild_id = $1;
").await?;
let rows = _db.query(&stmt, &[&guild_id]).await?;
let mut max_case_id = if let Some(row) = rows.get(0) {
row.get::<_, i32>("max_case_id")
} else {
0
};
// Increment the max case_id for the guild
max_case_id += 1;
let stmt = _db.prepare("
INSERT INTO guild_case_ids (guild_id, max_case_id) VALUES ($1, $2)
ON CONFLICT (guild_id) DO UPDATE SET max_case_id = $2;
").await?;
_db.execute(&stmt, &[&guild_id, &max_case_id]).await?;
// Create a new case in database and return the case_id
let stmt = _db.prepare("
INSERT INTO moderation_events (
guild_id, case_id, action_type, is_active, user_id, user_tag, reason, moderator_id, moderator_tag, time_created, duration
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING case_id;
").await?;
let row = _db.query(&stmt, &[
&guild_id,
&max_case_id,
&action_type.as_str(),
&is_active,
&user_id,
&user_tag,
&reason,
&moderator_id,
&moderator_tag,
&time_created,
&duration
]).await?;
let moderations = Moderations {
guild_id,
case_id: row[0].get("case_id"),
action_type,
is_active,
user_id,
user_tag,
reason,
moderator_id,
moderator_tag,
time_created,
duration
};
Ok(moderations)
}
pub async fn update_case(
guild_id: i64,
case_id: i32,
is_active: bool,
reason: Option<String>
) -> Result<(), tokio_postgres::Error> {
let _db = DatabaseController::new().await?.client;
match reason {
Some(reason) => {
_db.execute("
UPDATE moderation_events
SET is_active = $3, reason = $4 WHERE guild_id = $1 AND case_id = $2;
", &[&guild_id, &case_id, &is_active, &reason]).await?;
},
None => {
_db.execute("
UPDATE moderation_events
SET is_active = $3 WHERE guild_id = $1 AND case_id = $2;
", &[&guild_id, &case_id, &is_active]).await?;
}
}
Ok(())
}
}

View File

@ -1,23 +0,0 @@
use tokio::{
select,
signal::unix::{
SignalKind,
signal
}
};
pub async fn gracefully_shutdown() {
let [mut s1, mut s2, mut s3] = [
signal(SignalKind::interrupt()).unwrap(),
signal(SignalKind::hangup()).unwrap(),
signal(SignalKind::terminate()).unwrap()
];
select!(
v = s1.recv() => v.unwrap(),
v = s2.recv() => v.unwrap(),
v = s3.recv() => v.unwrap()
);
println!("\nKon says goodbye! 👋");
}

View File

@ -1,8 +0,0 @@
[package]
name = "kon_tokens"
version = "0.1.0"
edition = "2024"
[dependencies]
tokenservice-client = { version = "0.4.3", registry = "gitea" }
tokio = { workspace = true }

View File

@ -1,42 +0,0 @@
use {
std::{
env::args,
error::Error,
sync::LazyLock
},
tokenservice_client::{
TokenService,
TokenServiceApi
},
tokio::sync::Mutex
};
static TSCLIENT: LazyLock<Mutex<TSClient>> = LazyLock::new(|| Mutex::new(TSClient::default()));
pub struct TSClient(TokenService);
impl Default for TSClient {
fn default() -> Self { Self::new() }
}
impl TSClient {
pub fn new() -> Self {
let args: Vec<String> = args().collect();
let service = if args.len() > 1 { &args[1] } else { "kon" };
Self(TokenService::new(service))
}
pub async fn get(&self) -> Result<TokenServiceApi, Box<dyn Error + Send + Sync>> {
match self.0.connect().await {
Ok(a) => Ok(a),
Err(e) => Err(e)
}
}
}
pub async fn token_path() -> TokenServiceApi {
match TSCLIENT.lock().await.get().await {
Ok(a) => a,
Err(e) => panic!("TSClient[Error]: {e}")
}
}