Compare commits
3 Commits
master
...
module/mod
Author | SHA1 | Date | |
---|---|---|---|
8a3d90789d | |||
ca26eb6fd1 | |||
6d3033b3e7 |
@ -1,2 +0,0 @@
|
||||
[registries.gitea]
|
||||
index = "sparse+https://git.toast-server.net/api/packages/toast/cargo/"
|
@ -1,11 +1,7 @@
|
||||
.vscode
|
||||
.cargo
|
||||
target
|
||||
.env
|
||||
.gitignore
|
||||
.gitattributes
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
renovate.json
|
||||
run.sh
|
||||
target/debug/
|
||||
target/release/
|
||||
|
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -1 +0,0 @@
|
||||
libs/assets/FluidR3_GM.sf2 filter=lfs diff=lfs merge=lfs -text
|
@ -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
4
.gitignore
vendored
@ -1,6 +1,2 @@
|
||||
target
|
||||
.env
|
||||
*.log
|
||||
|
||||
# Local Gitea Actions
|
||||
act
|
||||
|
11
.vscode/extensions.json
vendored
11
.vscode/extensions.json
vendored
@ -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"
|
||||
]
|
||||
}
|
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@ -1,4 +1,6 @@
|
||||
{
|
||||
"rust-analyzer.showUnlinkedFileNotification": false,
|
||||
"rust-analyzer.linkedProjects": ["./Cargo.toml"]
|
||||
"rust-analyzer.linkedProjects": [
|
||||
"./Cargo.toml"
|
||||
],
|
||||
"rust-analyzer.showUnlinkedFileNotification": false
|
||||
}
|
||||
|
1945
Cargo.lock
generated
1945
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
52
Cargo.toml
52
Cargo.toml
@ -1,43 +1,23 @@
|
||||
[package]
|
||||
name = "kon"
|
||||
version = "0.6.2"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
members = ["cmds", "libs", "repo", "tasks", "tokens"]
|
||||
|
||||
[workspace.dependencies]
|
||||
bb8 = "0.9.0"
|
||||
bb8-redis = "0.18.0"
|
||||
cargo_toml = "0.21.0"
|
||||
feed-rs = "2.3.0"
|
||||
once_cell = "1.20.2"
|
||||
poise = "0.6.1"
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.9", features = ["json", "native-tls-vendored"] }
|
||||
serde = "1.0.216"
|
||||
serde_json = "1.0.133"
|
||||
sysinfo = "0.33.0"
|
||||
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_tasks = { path = "tasks" }
|
||||
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", "kon_tasks/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"
|
||||
|
18
Dockerfile
18
Dockerfile
@ -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 alpine:3.21
|
||||
LABEL org.opencontainers.image.source="https://git.toast-server.net/toast/Kon"
|
||||
RUN apk add --no-cache libgcc fluidsynth
|
||||
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" ]
|
||||
|
@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "kon_cmds"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
kon_libs = { workspace = true }
|
||||
kon_tokens = { workspace = true }
|
||||
lazy_static = { workspace = true }
|
||||
poise = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sysinfo = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
uptime_lib = { workspace = true }
|
@ -1,40 +0,0 @@
|
||||
mod ilo;
|
||||
mod midi;
|
||||
mod status;
|
||||
mod uptime;
|
||||
|
||||
use kon_libs::{
|
||||
KonData,
|
||||
KonError,
|
||||
KonResult,
|
||||
PoiseCtx
|
||||
};
|
||||
|
||||
use {
|
||||
ilo::ilo,
|
||||
midi::midi_to_wav,
|
||||
status::status,
|
||||
uptime::uptime
|
||||
};
|
||||
|
||||
macro_rules! commands {
|
||||
($($cmd:ident),*) => {
|
||||
vec![$($cmd()),*]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_cmds() -> Vec<poise::Command<KonData, KonError>> { commands!(deploy, ping, ilo, midi_to_wav, 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(())
|
||||
}
|
@ -1,369 +0,0 @@
|
||||
use {
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
const ILO_HOSTNAME: &str = "POMNI";
|
||||
|
||||
lazy_static! {
|
||||
static ref REQWEST_CLIENT: Client = ClientBuilder::new().danger_accept_invalid_certs(true).build().unwrap();
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Chassis {
|
||||
#[serde(rename = "Fans")]
|
||||
fans: Vec<Fan>,
|
||||
#[serde(rename = "Temperatures")]
|
||||
temperatures: Vec<Temperature>
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Fan {
|
||||
#[serde(rename = "CurrentReading")]
|
||||
current_reading: i32,
|
||||
#[serde(rename = "FanName")]
|
||||
fan_name: String,
|
||||
#[serde(rename = "Status")]
|
||||
status: Status
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Temperature {
|
||||
#[serde(rename = "CurrentReading")]
|
||||
current_reading: i32,
|
||||
#[serde(rename = "Name")]
|
||||
name: String,
|
||||
#[serde(rename = "ReadingCelsius")]
|
||||
reading_celsius: i32,
|
||||
#[serde(rename = "Status")]
|
||||
status: Status,
|
||||
#[serde(rename = "Units")]
|
||||
units: String,
|
||||
#[serde(rename = "UpperThresholdCritical")]
|
||||
upper_threshold_critical: i32,
|
||||
#[serde(rename = "UpperThresholdFatal")]
|
||||
upper_threshold_fatal: i32
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Status {
|
||||
#[serde(rename = "Health")]
|
||||
health: Option<String>,
|
||||
#[serde(rename = "State")]
|
||||
state: String
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct Power {
|
||||
#[serde(rename = "PowerCapacityWatts")]
|
||||
power_capacity_watts: i32,
|
||||
#[serde(rename = "PowerConsumedWatts")]
|
||||
power_consumed_watts: i32,
|
||||
#[serde(rename = "PowerMetrics")]
|
||||
power_metrics: PowerMetrics
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct PowerMetrics {
|
||||
#[serde(rename = "AverageConsumedWatts")]
|
||||
average_consumed_watts: i32,
|
||||
#[serde(rename = "MaxConsumedWatts")]
|
||||
max_consumed_watts: i32,
|
||||
#[serde(rename = "MinConsumedWatts")]
|
||||
min_consumed_watts: i32
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct System {
|
||||
#[serde(rename = "Memory")]
|
||||
memory: Memory,
|
||||
#[serde(rename = "Model")]
|
||||
model: String,
|
||||
#[serde(rename = "Oem")]
|
||||
oem: Oem,
|
||||
#[serde(rename = "PowerState")]
|
||||
power_state: String,
|
||||
#[serde(rename = "ProcessorSummary")]
|
||||
processor_summary: ProcessorSummary
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Memory {
|
||||
#[serde(rename = "TotalSystemMemoryGB")]
|
||||
total_system_memory: i32
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ProcessorSummary {
|
||||
#[serde(rename = "Count")]
|
||||
count: i32,
|
||||
#[serde(rename = "Model")]
|
||||
cpu: String
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Oem {
|
||||
#[serde(rename = "Hp")]
|
||||
hp: Hp
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
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)]
|
||||
struct ImlEntry {
|
||||
#[serde(rename = "Created")]
|
||||
created: String,
|
||||
#[serde(rename = "Message")]
|
||||
message: String,
|
||||
#[serde(rename = "Severity")]
|
||||
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 = match temp.name.as_str() {
|
||||
"01-Inlet Ambient" => "Inlet Ambient",
|
||||
"04-P1 DIMM 1-6" => "P1 DIMM 1-6",
|
||||
"14-Chipset Zone" => "Chipset Zone",
|
||||
_ => "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 mut powerdata = String::new();
|
||||
|
||||
powerdata.push_str(&format!("**Power Capacity:** `{}w`\n", &data.power_capacity_watts));
|
||||
powerdata.push_str(&format!("**Power Consumed:** `{}w`\n", &data.power_consumed_watts));
|
||||
powerdata.push_str(&format!("**Average Power:** `{}w`\n", &data.power_metrics.average_consumed_watts));
|
||||
powerdata.push_str(&format!("**Max Consumed:** `{}w`\n", &data.power_metrics.max_consumed_watts));
|
||||
powerdata.push_str(&format!("**Min Consumed:** `{}w`", &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 = match ilo_sys.oem.hp.post_state.as_str() {
|
||||
"FinishedPost" => "Finished POST",
|
||||
"InPost" => "In POST (Booting)",
|
||||
"PowerOff" => "Powered off",
|
||||
_ => "Unknown 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.cpu.trim().to_string(),
|
||||
true
|
||||
),
|
||||
("RAM".to_string(), format!("{} GB", ilo_sys.memory.total_system_memory), 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(())
|
||||
}
|
@ -1,113 +0,0 @@
|
||||
use {
|
||||
kon_libs::{
|
||||
KonError,
|
||||
KonResult,
|
||||
format_bytes,
|
||||
mention_dev
|
||||
},
|
||||
poise::{
|
||||
CreateReply,
|
||||
serenity_prelude::{
|
||||
CreateAttachment,
|
||||
Message
|
||||
}
|
||||
},
|
||||
regex::Regex,
|
||||
std::{
|
||||
fs::{
|
||||
metadata,
|
||||
remove_file,
|
||||
write
|
||||
},
|
||||
os::unix::fs::MetadataExt
|
||||
}
|
||||
};
|
||||
|
||||
/// Convert MIDI file to WAV
|
||||
#[poise::command(
|
||||
context_menu_command = "MIDI -> WAV",
|
||||
install_context = "Guild|User",
|
||||
interaction_context = "Guild|BotDm|PrivateChannel"
|
||||
)]
|
||||
pub async fn midi_to_wav(
|
||||
ctx: super::PoiseCtx<'_>,
|
||||
#[description = "MIDI file to be converted"] message: Message
|
||||
) -> KonResult<()> {
|
||||
let re = Regex::new(r"(?i)\.mid$").unwrap();
|
||||
|
||||
if !message.embeds.is_empty() || message.attachments.is_empty() || !re.is_match(&message.attachments[0].filename) {
|
||||
ctx.reply("That ain't a MIDI file! What are you even doing??").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ctx.defer().await?;
|
||||
|
||||
let bytes = match message.attachments[0].download().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(y) => {
|
||||
ctx
|
||||
.send(CreateReply::default().content(format!(
|
||||
"Download failed, ask {} to check console for more information!",
|
||||
mention_dev(ctx).unwrap_or_default()
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
return Err(KonError::from(format!("Failed to download the file: {y}")))
|
||||
}
|
||||
};
|
||||
|
||||
let midi_path = &message.attachments[0].filename;
|
||||
write(midi_path, bytes)?;
|
||||
|
||||
let wav_path = re.replace(midi_path, ".wav");
|
||||
|
||||
let sf2_path = "/tmp/FluidR3_GM.sf2";
|
||||
write(sf2_path, include_bytes!("../../../libs/assets/FluidR3_GM.sf2"))?;
|
||||
|
||||
let output = std::process::Command::new("fluidsynth")
|
||||
.args(["-ni", sf2_path, midi_path, "-F", &wav_path])
|
||||
.output();
|
||||
|
||||
// Just to add an info to console to tell what the bot is doing when MIDI file is downloaded.
|
||||
println!("Discord[{}]: Processing MIDI file: \"{midi_path}\"", ctx.command().qualified_name);
|
||||
|
||||
match output {
|
||||
Ok(_) => {
|
||||
let reply = ctx
|
||||
.send(CreateReply::default().attachment(CreateAttachment::path(&*wav_path).await.unwrap()))
|
||||
.await;
|
||||
|
||||
if reply.is_err() {
|
||||
println!(
|
||||
"Discord[{}]: Processed file couldn't be uploaded back to Discord channel due to upload limit",
|
||||
ctx.command().qualified_name
|
||||
);
|
||||
|
||||
ctx
|
||||
.send(CreateReply::default().content(format!(
|
||||
"Couldn't upload the processed file (`{}`, `{}`) due to upload limit",
|
||||
&*wav_path,
|
||||
format_bytes(metadata(&*wav_path).unwrap().size())
|
||||
)))
|
||||
.await?;
|
||||
} else if reply.is_ok() {
|
||||
println!(
|
||||
"Discord[{}]: Processed file uploaded back to Discord channel",
|
||||
ctx.command().qualified_name
|
||||
);
|
||||
remove_file(midi_path)?;
|
||||
remove_file(&*wav_path)?;
|
||||
}
|
||||
},
|
||||
Err(y) => {
|
||||
ctx
|
||||
.send(CreateReply::default().content("Command didn't execute successfully, check console for more information!"))
|
||||
.await?;
|
||||
|
||||
return Err(KonError::from(format!("Midi conversion failed: {y}")))
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
@ -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(())
|
||||
}
|
@ -1,80 +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}")
|
||||
}
|
||||
|
||||
/// 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 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!("**{} {}** `{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!("OS: `{}`", get_os_info())
|
||||
];
|
||||
ctx.reply(stat_msg.join("\n")).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
mod dispatch;
|
||||
pub use dispatch::*;
|
@ -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}
|
||||
|
@ -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 = []
|
BIN
libs/assets/FluidR3_GM.sf2
(Stored with Git LFS)
BIN
libs/assets/FluidR3_GM.sf2
(Stored with Git LFS)
Binary file not shown.
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
pub struct KonData {}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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}")
|
||||
}
|
||||
}
|
@ -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>;
|
@ -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"],
|
||||
|
@ -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 }
|
@ -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
|
||||
}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
mod cache;
|
||||
pub use cache::RedisController;
|
2
run.sh
2
run.sh
@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
clear && cargo fmt && cargo run kon_dev
|
||||
export $(grep -v '^#' .env | xargs) && cargo run kon_dev
|
||||
|
@ -1,2 +0,0 @@
|
||||
[toolchain]
|
||||
channel = "nightly"
|
20
rustfmt.toml
20
rustfmt.toml
@ -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
265
src/commands/gameserver.rs
Normal 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
5
src/commands/mod.rs
Normal 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
203
src/commands/moderation.rs
Normal 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
8
src/commands/ping.rs
Normal 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
164
src/commands/status.rs
Normal 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
45
src/commands/uptime.rs
Normal 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(())
|
||||
}
|
64
src/controllers/database.rs
Normal file
64
src/controllers/database.rs
Normal 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
2
src/controllers/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod database;
|
||||
pub mod timers;
|
95
src/controllers/timers.rs
Normal file
95
src/controllers/timers.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
30
src/internals/http.rs
Normal file
30
src/internals/http.rs
Normal 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
3
src/internals/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod utils;
|
||||
pub mod http;
|
||||
pub mod tsclient;
|
19
src/internals/tsclient.rs
Normal file
19
src/internals/tsclient.rs
Normal 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
47
src/internals/utils.rs
Normal 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
|
||||
}
|
196
src/main.rs
196
src/main.rs
@ -1,82 +1,56 @@
|
||||
// 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,
|
||||
PoiseFwCtx,
|
||||
mention_dev
|
||||
use poise::serenity_prelude::{
|
||||
builder::{
|
||||
CreateMessage,
|
||||
CreateEmbed,
|
||||
CreateEmbedAuthor
|
||||
},
|
||||
kon_tasks::{
|
||||
rss,
|
||||
run_task
|
||||
},
|
||||
kon_tokens::token_path,
|
||||
poise::serenity_prelude::{
|
||||
ChannelId,
|
||||
ClientBuilder,
|
||||
Context,
|
||||
FullEvent,
|
||||
GatewayIntents,
|
||||
Ready,
|
||||
builder::{
|
||||
CreateEmbed,
|
||||
CreateEmbedAuthor,
|
||||
CreateMessage
|
||||
}
|
||||
},
|
||||
std::{
|
||||
borrow::Cow,
|
||||
sync::Arc,
|
||||
thread::current
|
||||
}
|
||||
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;
|
||||
|
||||
async fn event_processor(
|
||||
framework: PoiseFwCtx<'_>,
|
||||
event: &FullEvent
|
||||
) -> KonResult<()> {
|
||||
if let FullEvent::Ready { .. } = event {
|
||||
let thread_id = format!("{:?}", current().id());
|
||||
let thread_num: String = thread_id.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
println!("Event[Ready]: Task Scheduler operating on thread {thread_num}");
|
||||
|
||||
let ctx = Arc::new(framework.serenity_context.clone());
|
||||
run_task(ctx.clone(), rss).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(())
|
||||
@ -84,70 +58,54 @@ async fn event_processor(
|
||||
|
||||
#[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 {
|
||||
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}")
|
||||
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,
|
||||
event_handler: |framework, event| Box::pin(event_processor(framework, event)),
|
||||
..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 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
103
src/models/gameservers.rs
Normal 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
2
src/models/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod gameservers;
|
||||
pub mod moderation_events;
|
215
src/models/moderation_events.rs
Normal file
215
src/models/moderation_events.rs
Normal 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(())
|
||||
}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "kon_tasks"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
feed-rs = { workspace = true }
|
||||
kon_libs = { workspace = true }
|
||||
kon_repo = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
poise = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[features]
|
||||
production = ["kon_libs/production"]
|
@ -1,58 +0,0 @@
|
||||
mod rss;
|
||||
|
||||
pub use rss::rss;
|
||||
|
||||
use {
|
||||
kon_libs::KonResult,
|
||||
poise::serenity_prelude::Context,
|
||||
std::{
|
||||
future::Future,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{
|
||||
AtomicBool,
|
||||
Ordering
|
||||
}
|
||||
}
|
||||
},
|
||||
tokio::task::spawn
|
||||
};
|
||||
|
||||
fn task_info(
|
||||
name: &str,
|
||||
message: &str
|
||||
) {
|
||||
println!("TaskScheduler[{name}]: {message}")
|
||||
}
|
||||
|
||||
fn task_err(
|
||||
name: &str,
|
||||
message: &str
|
||||
) {
|
||||
eprintln!("TaskScheduler[{name}:Error]: {message}")
|
||||
}
|
||||
|
||||
static TASK_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub async fn run_task<F, T>(
|
||||
ctx: Arc<Context>,
|
||||
task: F
|
||||
) where
|
||||
F: Fn(Arc<Context>) -> T + Send + 'static,
|
||||
T: Future<Output = KonResult<()>> + Send + 'static
|
||||
{
|
||||
let ctx_cl = Arc::clone(&ctx);
|
||||
|
||||
if !TASK_RUNNING.load(Ordering::SeqCst) {
|
||||
TASK_RUNNING.store(true, Ordering::SeqCst);
|
||||
spawn(async move {
|
||||
if let Err(y) = task(ctx_cl).await {
|
||||
eprintln!("TaskScheduler[Main:Error]: Failed to execute the task, error reason: {y}");
|
||||
if let Some(source) = y.source() {
|
||||
eprintln!("TaskScheduler[Main:Error]: Failed to execute the task, this is caused by: {source:#?}");
|
||||
}
|
||||
}
|
||||
TASK_RUNNING.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
206
tasks/src/rss.rs
206
tasks/src/rss.rs
@ -1,206 +0,0 @@
|
||||
mod processor; // Process the feeds and send it off to Discord
|
||||
|
||||
mod esxi;
|
||||
mod github;
|
||||
mod gportal;
|
||||
mod rust;
|
||||
|
||||
use {
|
||||
esxi::Esxi,
|
||||
github::GitHub,
|
||||
gportal::GPortal,
|
||||
rust::RustBlog
|
||||
};
|
||||
|
||||
use super::{
|
||||
task_err,
|
||||
task_info
|
||||
};
|
||||
|
||||
use {
|
||||
feed_rs::parser::parse,
|
||||
kon_libs::{
|
||||
HttpClient,
|
||||
KonResult
|
||||
},
|
||||
kon_repo::RedisController,
|
||||
once_cell::sync::OnceCell,
|
||||
poise::serenity_prelude::{
|
||||
Context,
|
||||
CreateEmbed,
|
||||
Timestamp,
|
||||
async_trait
|
||||
},
|
||||
regex::Regex,
|
||||
reqwest::Response,
|
||||
std::sync::Arc,
|
||||
tokio::time::{
|
||||
Duration,
|
||||
interval
|
||||
}
|
||||
};
|
||||
|
||||
pub type RSSFeedBox = Box<dyn RSSFeed + Send + Sync>;
|
||||
|
||||
const TASK_NAME: &str = "RSS";
|
||||
static REDIS_EXPIRY_SECS: i64 = 7200;
|
||||
static REDIS_SERVICE: OnceCell<Arc<RedisController>> = OnceCell::new();
|
||||
|
||||
async fn redis_() {
|
||||
let redis = RedisController::new().await.unwrap();
|
||||
REDIS_SERVICE.set(Arc::new(redis)).unwrap();
|
||||
}
|
||||
|
||||
async fn get_redis() -> Arc<RedisController> {
|
||||
if REDIS_SERVICE.get().is_none() {
|
||||
redis_().await;
|
||||
}
|
||||
REDIS_SERVICE.get().unwrap().clone()
|
||||
}
|
||||
|
||||
fn format_href_to_discord(input: &str) -> String {
|
||||
let re = Regex::new(r#"<a href="([^"]+)">([^<]+)</a>"#).unwrap();
|
||||
re.replace_all(input, r"[$2]($1)").to_string()
|
||||
}
|
||||
|
||||
fn format_html_to_discord(input: String) -> String {
|
||||
let mut output = input;
|
||||
|
||||
// Replace all instances of <p> and </p> with newlines
|
||||
output = Regex::new(r#"</?\s*p\s*>"#).unwrap().replace_all(&output, "\n").to_string();
|
||||
|
||||
// Replace all instances of <br> and <br /> with newlines
|
||||
output = Regex::new(r#"<\s*br\s*/?\s*>"#).unwrap().replace_all(&output, "\n").to_string();
|
||||
|
||||
// Replace all instances of <strong> with **
|
||||
output = Regex::new(r#"</?\s*strong\s*>"#).unwrap().replace_all(&output, "**").to_string();
|
||||
|
||||
// Replace all instances of <var> and <small> with nothing
|
||||
output = Regex::new(r#"</?\s*(var|small)\s*>"#).unwrap().replace_all(&output, "").to_string();
|
||||
|
||||
// Remove any other HTML tags
|
||||
output = Regex::new(r#"<[^>]+>"#).unwrap().replace_all(&output, "").to_string();
|
||||
|
||||
// Replace all instances of <a href="url">text</a> with [text](url)
|
||||
output = format_href_to_discord(&output);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
async fn fetch_feed(url: &str) -> KonResult<Response> {
|
||||
let http = HttpClient::new();
|
||||
let res = match http.get(url, "RSS-Monitor").await {
|
||||
Ok(res) => res,
|
||||
Err(y) => return Err(y.into())
|
||||
};
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
async fn save_to_redis(
|
||||
key: &str,
|
||||
value: &str
|
||||
) -> KonResult<()> {
|
||||
let redis = get_redis().await;
|
||||
redis.set(key, value).await.unwrap();
|
||||
if let Err(y) = redis.expire(key, REDIS_EXPIRY_SECS).await {
|
||||
task_err("RSS", format!("[RedisExpiry]: {y}").as_str());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn embed(
|
||||
color: u32,
|
||||
title: String,
|
||||
url: String,
|
||||
description: String,
|
||||
timestamp: Timestamp
|
||||
) -> CreateEmbed {
|
||||
CreateEmbed::new()
|
||||
.color(color)
|
||||
.title(title)
|
||||
.url(url)
|
||||
.description(description)
|
||||
.timestamp(timestamp)
|
||||
}
|
||||
|
||||
const MAX_CONTENT_LENGTH: usize = 4000;
|
||||
fn trim_old_content(s: &str) -> String {
|
||||
if s.len() > MAX_CONTENT_LENGTH {
|
||||
s[..MAX_CONTENT_LENGTH].to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
enum IncidentColorMap {
|
||||
Update,
|
||||
Investigating,
|
||||
Monitoring,
|
||||
Resolved,
|
||||
Default
|
||||
}
|
||||
|
||||
impl IncidentColorMap {
|
||||
fn color(&self) -> u32 {
|
||||
match self {
|
||||
Self::Update => 0xABDD9E, // Madang
|
||||
Self::Investigating => 0xA5CCE0, // French Pass
|
||||
Self::Monitoring => 0x81CBAD, // Monte Carlo
|
||||
Self::Resolved => 0x57F287, // Emerald
|
||||
Self::Default => 0x81CBAD // Monte Carlo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RSSFeed {
|
||||
fn name(&self) -> &str;
|
||||
fn url(&self) -> &str;
|
||||
async fn process(
|
||||
&self,
|
||||
ctx: Arc<Context>
|
||||
) -> KonResult<Option<RSSFeedOutput>>;
|
||||
}
|
||||
|
||||
/// Handle feed's output type for Discord message
|
||||
pub enum RSSFeedOutput {
|
||||
RegularEmbed(CreateEmbed),
|
||||
IncidentEmbed(CreateEmbed),
|
||||
Content(String)
|
||||
}
|
||||
|
||||
pub async fn rss(ctx: Arc<Context>) -> KonResult<()> {
|
||||
#[cfg(feature = "production")]
|
||||
let mut interval = interval(Duration::from_secs(300)); // Check feeds every 5 mins
|
||||
#[cfg(not(feature = "production"))]
|
||||
let mut interval = interval(Duration::from_secs(30)); // Check feeds every 30 secs
|
||||
let mut first_run = true;
|
||||
task_info(TASK_NAME, "Task loaded!");
|
||||
|
||||
let feeds: Vec<RSSFeedBox> = vec![
|
||||
Box::new(Esxi::new("https://esxi-patches.v-front.de/atom/ESXi-7.0.0.xml".to_string())),
|
||||
Box::new(GitHub::new("https://www.githubstatus.com/history.atom".to_string())),
|
||||
Box::new(GPortal::new("https://status.g-portal.com/history.atom".to_string())),
|
||||
Box::new(RustBlog::new("https://blog.rust-lang.org/feed.xml".to_string())),
|
||||
];
|
||||
|
||||
let mut processor = processor::RSSProcessor::new();
|
||||
|
||||
for feed in feeds {
|
||||
processor.add_feed(feed);
|
||||
}
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
if first_run {
|
||||
task_info(&format!("{TASK_NAME}:Processor"), "Starting up!");
|
||||
first_run = false;
|
||||
}
|
||||
|
||||
if let Err(e) = processor.process_all(ctx.clone()).await {
|
||||
task_err(&format!("{TASK_NAME}:Processor"), &e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
use super::{
|
||||
RSSFeed,
|
||||
RSSFeedOutput,
|
||||
fetch_feed,
|
||||
format_href_to_discord,
|
||||
get_redis,
|
||||
parse,
|
||||
save_to_redis,
|
||||
task_err
|
||||
};
|
||||
|
||||
use {
|
||||
kon_libs::KonResult,
|
||||
poise::serenity_prelude::{
|
||||
Context,
|
||||
CreateEmbed,
|
||||
CreateEmbedAuthor,
|
||||
Timestamp,
|
||||
async_trait
|
||||
},
|
||||
regex::Regex,
|
||||
std::{
|
||||
io::Cursor,
|
||||
sync::Arc
|
||||
}
|
||||
};
|
||||
|
||||
pub struct Esxi {
|
||||
url: String
|
||||
}
|
||||
|
||||
impl Esxi {
|
||||
pub fn new(url: String) -> Self { Self { url } }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RSSFeed for Esxi {
|
||||
fn name(&self) -> &str { "ESXi" }
|
||||
|
||||
fn url(&self) -> &str { self.url.as_str() }
|
||||
|
||||
async fn process(
|
||||
&self,
|
||||
_ctx: Arc<Context>
|
||||
) -> KonResult<Option<RSSFeedOutput>> {
|
||||
let redis = get_redis().await;
|
||||
let rkey = "RSS_ESXi";
|
||||
|
||||
let res = fetch_feed(self.url()).await?;
|
||||
let data = res.text().await?;
|
||||
let cursor = Cursor::new(data);
|
||||
|
||||
let feed = parse(cursor).map_err(|e| {
|
||||
task_err("RSS:ESXi", &format!("Error parsing RSS feed: {e}"));
|
||||
e
|
||||
})?;
|
||||
|
||||
if feed.entries.is_empty() {
|
||||
task_err("RSS:ESXi", "No entries found in the feed!");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let home_page = feed.links[0].clone().href;
|
||||
let article = feed.entries[0].clone();
|
||||
|
||||
fn get_patch_version(input: &str) -> Option<String> {
|
||||
let re = Regex::new(r#"(?i)Update\s+([0-9]+)([a-z]?)"#).unwrap();
|
||||
|
||||
if let Some(caps) = re.captures(input) {
|
||||
let update_num = caps[1].to_string();
|
||||
let letter = caps.get(2).map_or("", |m| m.as_str());
|
||||
Some(format!("Update {update_num}{letter}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
let cached_patch = redis.get(rkey).await.unwrap_or(None).unwrap_or_default();
|
||||
|
||||
if cached_patch.is_empty() {
|
||||
save_to_redis(rkey, &article.categories[3].term).await?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(patch) = get_patch_version(&article.categories[3].term) {
|
||||
if patch == cached_patch {
|
||||
Ok(None)
|
||||
} else {
|
||||
save_to_redis(rkey, &article.categories[3].term).await?;
|
||||
|
||||
Ok(Some(RSSFeedOutput::RegularEmbed(
|
||||
CreateEmbed::new()
|
||||
.color(0x4EFBCB)
|
||||
.author(CreateEmbedAuthor::new(feed.title.unwrap().content).url(home_page))
|
||||
.thumbnail(feed.logo.unwrap().uri)
|
||||
.description(format!(
|
||||
"{} {} for {} {} has been rolled out!\n{}",
|
||||
article.categories[2].term,
|
||||
article.categories[3].term,
|
||||
article.categories[0].term,
|
||||
article.categories[1].term,
|
||||
format_href_to_discord(&article.summary.unwrap().content)
|
||||
))
|
||||
.timestamp(Timestamp::from(article.updated.unwrap()))
|
||||
)))
|
||||
}
|
||||
} else {
|
||||
task_err(
|
||||
"RSS:ESXi",
|
||||
&format!("Article term does not match the expected RegEx pattern! ({})", article.categories[3].term)
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,144 +0,0 @@
|
||||
use super::{
|
||||
IncidentColorMap,
|
||||
RSSFeed,
|
||||
RSSFeedOutput,
|
||||
embed,
|
||||
fetch_feed,
|
||||
format_html_to_discord,
|
||||
get_redis,
|
||||
parse,
|
||||
save_to_redis,
|
||||
task_err,
|
||||
task_info,
|
||||
trim_old_content
|
||||
};
|
||||
|
||||
use {
|
||||
kon_libs::KonResult,
|
||||
poise::serenity_prelude::{
|
||||
Context,
|
||||
Timestamp,
|
||||
async_trait
|
||||
},
|
||||
regex::Regex,
|
||||
std::{
|
||||
io::Cursor,
|
||||
sync::Arc
|
||||
}
|
||||
};
|
||||
|
||||
pub struct GitHub {
|
||||
url: String
|
||||
}
|
||||
|
||||
impl GitHub {
|
||||
pub fn new(url: String) -> Self { Self { url } }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RSSFeed for GitHub {
|
||||
fn name(&self) -> &str { "GitHub" }
|
||||
|
||||
fn url(&self) -> &str { self.url.as_str() }
|
||||
|
||||
async fn process(
|
||||
&self,
|
||||
_ctx: Arc<Context>
|
||||
) -> KonResult<Option<RSSFeedOutput>> {
|
||||
let redis = get_redis().await;
|
||||
let rkey = "RSS_GitHub";
|
||||
let rkey_content = format!("{rkey}_Content");
|
||||
|
||||
let res = fetch_feed(self.url()).await?;
|
||||
let data = res.text().await?;
|
||||
let cursor = Cursor::new(data);
|
||||
|
||||
let feed = parse(cursor).map_err(|e| {
|
||||
task_err("RSS:GitHub", &format!("Error parsing RSS feed: {e}"));
|
||||
e
|
||||
})?;
|
||||
|
||||
if feed.entries.is_empty() {
|
||||
task_err("RSS:GitHub", "No entries found in the feed!");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let incident_page = feed.entries[0].links[0].clone().href;
|
||||
let article = feed.entries[0].clone();
|
||||
|
||||
fn get_incident_id(input: &str) -> Option<String> {
|
||||
let re = Regex::new(r#"/incidents/([a-zA-Z0-9]+)$"#).unwrap();
|
||||
re.captures(input).map(|caps| caps[1].to_string())
|
||||
}
|
||||
|
||||
let cached_incident = redis.get(rkey).await.unwrap().unwrap_or_default();
|
||||
let new_content = format_html_to_discord(article.content.unwrap().body.unwrap());
|
||||
|
||||
let update_patt = Regex::new(r"(?i)\bupdate\b").unwrap();
|
||||
let investigating_patt = Regex::new(r"(?i)\binvestigating\b").unwrap();
|
||||
let resolved_patt = Regex::new(r"(?i)\bresolved\b").unwrap();
|
||||
let date_patt = Regex::new(r"\b[A-Z][a-z]{2} \d{2}, \d{2}:\d{2} UTC\b").unwrap();
|
||||
|
||||
let first_entry = date_patt
|
||||
.split(&new_content)
|
||||
.map(str::trim)
|
||||
.find(|e| !e.is_empty())
|
||||
.unwrap_or(&new_content);
|
||||
|
||||
let color: u32 = if update_patt.is_match(first_entry) {
|
||||
IncidentColorMap::Update.color()
|
||||
} else if investigating_patt.is_match(first_entry) {
|
||||
IncidentColorMap::Investigating.color()
|
||||
} else if resolved_patt.is_match(first_entry) {
|
||||
IncidentColorMap::Resolved.color()
|
||||
} else {
|
||||
IncidentColorMap::Default.color()
|
||||
};
|
||||
|
||||
task_info("RSS:GitHub:Debug", &format!("Checking cache for incident ID: {}", &article.links[0].href));
|
||||
if cached_incident.is_empty() {
|
||||
save_to_redis(rkey, &get_incident_id(&article.links[0].href).unwrap()).await?;
|
||||
save_to_redis(&rkey_content, &new_content).await?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(incident) = get_incident_id(&article.links[0].href) {
|
||||
if incident == cached_incident {
|
||||
let cached_content = redis.get(&rkey_content).await.unwrap().unwrap_or_default();
|
||||
if cached_content == new_content {
|
||||
Ok(None)
|
||||
} else {
|
||||
redis.set(&rkey_content, &new_content).await.unwrap();
|
||||
redis.expire(&rkey_content, 21600).await.unwrap();
|
||||
task_info("RSS:GitHub:Debug", "Incident added in cache and preparing to send embed to Discord");
|
||||
|
||||
Ok(Some(RSSFeedOutput::IncidentEmbed(embed(
|
||||
color,
|
||||
article.title.unwrap().content,
|
||||
incident_page,
|
||||
trim_old_content(&new_content),
|
||||
Timestamp::from(article.updated.unwrap())
|
||||
))))
|
||||
}
|
||||
} else {
|
||||
save_to_redis(rkey, &incident).await?;
|
||||
redis.set(&rkey_content, &new_content).await.unwrap();
|
||||
task_info("RSS:GitHub:Debug", "Incident updated in cache and preparing to send embed to Discord");
|
||||
|
||||
Ok(Some(RSSFeedOutput::IncidentEmbed(embed(
|
||||
color,
|
||||
article.title.unwrap().content,
|
||||
incident_page,
|
||||
trim_old_content(&new_content),
|
||||
Timestamp::from(article.updated.unwrap())
|
||||
))))
|
||||
}
|
||||
} else {
|
||||
task_err(
|
||||
"RSS:GitHub",
|
||||
&format!("Incident ID does not match the expected RegEx pattern! ({})", &article.links[0].href)
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,143 +0,0 @@
|
||||
use super::{
|
||||
IncidentColorMap,
|
||||
RSSFeed,
|
||||
RSSFeedOutput,
|
||||
embed,
|
||||
fetch_feed,
|
||||
format_html_to_discord,
|
||||
get_redis,
|
||||
parse,
|
||||
save_to_redis,
|
||||
task_err,
|
||||
trim_old_content
|
||||
};
|
||||
|
||||
use {
|
||||
kon_libs::KonResult,
|
||||
poise::serenity_prelude::{
|
||||
Context,
|
||||
Timestamp,
|
||||
async_trait
|
||||
},
|
||||
regex::Regex,
|
||||
std::{
|
||||
io::Cursor,
|
||||
sync::Arc
|
||||
}
|
||||
};
|
||||
|
||||
pub struct GPortal {
|
||||
url: String
|
||||
}
|
||||
|
||||
impl GPortal {
|
||||
pub fn new(url: String) -> Self { Self { url } }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RSSFeed for GPortal {
|
||||
fn name(&self) -> &str { "GPortal" }
|
||||
|
||||
fn url(&self) -> &str { self.url.as_str() }
|
||||
|
||||
async fn process(
|
||||
&self,
|
||||
_ctx: Arc<Context>
|
||||
) -> KonResult<Option<RSSFeedOutput>> {
|
||||
let redis = get_redis().await;
|
||||
let rkey = "RSS_GPortal";
|
||||
let rkey_content = format!("{rkey}_Content");
|
||||
|
||||
let res = fetch_feed(self.url()).await?;
|
||||
let data = res.text().await?;
|
||||
let cursor = Cursor::new(data);
|
||||
|
||||
let feed = parse(cursor).map_err(|e| {
|
||||
task_err("RSS:GPortal", &format!("Error parsing RSS feed: {e}"));
|
||||
e
|
||||
})?;
|
||||
|
||||
if feed.entries.is_empty() {
|
||||
task_err("RSS:GPortal", "No entries found in the feed!");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let incident_page = feed.links[0].clone().href;
|
||||
let article = feed.entries[0].clone();
|
||||
|
||||
fn get_incident_id(input: &str) -> Option<String> {
|
||||
let re = Regex::new(r#"/incidents/([a-zA-Z0-9]+)$"#).unwrap();
|
||||
re.captures(input).map(|caps| caps[1].to_string())
|
||||
}
|
||||
|
||||
let cached_incident = redis.get(rkey).await.unwrap().unwrap_or_default();
|
||||
let new_content = format_html_to_discord(article.content.unwrap().body.unwrap());
|
||||
|
||||
let update_patt = Regex::new(r"(?i)\bupdate\b").unwrap();
|
||||
let investigating_patt = Regex::new(r"(?i)\binvestigating\b").unwrap();
|
||||
let monitoring_patt = Regex::new(r"(?i)\bmonitoring\b").unwrap();
|
||||
let resolved_patt = Regex::new(r"(?i)\bresolved\b").unwrap();
|
||||
let date_patt = Regex::new(r"\b[A-Z][a-z]{2} \d{2}, \d{2}:\d{2} UTC\b").unwrap();
|
||||
|
||||
let first_entry = date_patt
|
||||
.split(&new_content)
|
||||
.map(str::trim)
|
||||
.find(|e| !e.is_empty())
|
||||
.unwrap_or(&new_content);
|
||||
|
||||
let color: u32 = if update_patt.is_match(first_entry) {
|
||||
IncidentColorMap::Update.color()
|
||||
} else if investigating_patt.is_match(first_entry) {
|
||||
IncidentColorMap::Investigating.color()
|
||||
} else if monitoring_patt.is_match(first_entry) {
|
||||
IncidentColorMap::Monitoring.color()
|
||||
} else if resolved_patt.is_match(first_entry) {
|
||||
IncidentColorMap::Resolved.color()
|
||||
} else {
|
||||
IncidentColorMap::Default.color()
|
||||
};
|
||||
|
||||
if cached_incident.is_empty() {
|
||||
save_to_redis(rkey, &get_incident_id(&article.links[0].href).unwrap()).await?;
|
||||
save_to_redis(&rkey_content, &new_content).await?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(incident) = get_incident_id(&article.links[0].href) {
|
||||
if incident == cached_incident {
|
||||
let cached_content = redis.get(&rkey_content).await.unwrap().unwrap_or_default();
|
||||
if cached_content == new_content {
|
||||
Ok(None)
|
||||
} else {
|
||||
redis.set(&rkey_content, &new_content).await.unwrap();
|
||||
redis.expire(&rkey_content, 21600).await.unwrap();
|
||||
|
||||
Ok(Some(RSSFeedOutput::IncidentEmbed(embed(
|
||||
color,
|
||||
article.title.unwrap().content,
|
||||
incident_page,
|
||||
trim_old_content(&new_content),
|
||||
Timestamp::from(article.updated.unwrap())
|
||||
))))
|
||||
}
|
||||
} else {
|
||||
save_to_redis(rkey, &incident).await?;
|
||||
redis.set(&rkey_content, &new_content).await.unwrap();
|
||||
|
||||
Ok(Some(RSSFeedOutput::IncidentEmbed(embed(
|
||||
color,
|
||||
article.title.unwrap().content,
|
||||
incident_page,
|
||||
trim_old_content(&new_content),
|
||||
Timestamp::from(article.updated.unwrap())
|
||||
))))
|
||||
}
|
||||
} else {
|
||||
task_err(
|
||||
"RSS:GPortal",
|
||||
&format!("Incident ID does not match the expected RegEx pattern! ({})", &article.links[0].href)
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,173 +0,0 @@
|
||||
use kon_libs::{
|
||||
BINARY_PROPERTIES,
|
||||
KonResult
|
||||
};
|
||||
|
||||
use super::{
|
||||
RSSFeedBox,
|
||||
RSSFeedOutput,
|
||||
TASK_NAME,
|
||||
get_redis
|
||||
};
|
||||
|
||||
use {
|
||||
poise::serenity_prelude::{
|
||||
ChannelId,
|
||||
Context,
|
||||
CreateEmbed,
|
||||
CreateMessage,
|
||||
EditMessage,
|
||||
Http
|
||||
},
|
||||
regex::Regex,
|
||||
std::sync::Arc
|
||||
};
|
||||
|
||||
// This is for building up the embed with the feed data
|
||||
/* std::fs::File::create("rss_name.log").unwrap();
|
||||
std::fs::write("rss_name.log", format!("{:#?}", feed))?; */
|
||||
|
||||
async fn process_regular_embed(
|
||||
http: &Http,
|
||||
embed: CreateEmbed,
|
||||
redis_key: &str
|
||||
) -> KonResult<()> {
|
||||
let redis = get_redis().await;
|
||||
let channel = ChannelId::new(BINARY_PROPERTIES.rss_channel);
|
||||
|
||||
let msg_id_key: Option<String> = redis.get(redis_key).await?;
|
||||
|
||||
if let Some(msg_id_key) = msg_id_key {
|
||||
if let Ok(msg_id) = msg_id_key.parse::<u64>() {
|
||||
if let Ok(mut message) = channel.message(http, msg_id).await {
|
||||
message.edit(http, EditMessage::new().embed(embed)).await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let message = channel.send_message(http, CreateMessage::new().add_embed(embed)).await?;
|
||||
redis.set(redis_key, &message.id.to_string()).await?;
|
||||
redis.expire(redis_key, 36000).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cache-based embed updater for ongoing outages/incidents
|
||||
async fn process_incident_embed(
|
||||
http: &Http,
|
||||
embed: CreateEmbed,
|
||||
redis_key: &str,
|
||||
content_key: &str
|
||||
) -> KonResult<()> {
|
||||
let redis = get_redis().await;
|
||||
let channel = ChannelId::new(BINARY_PROPERTIES.rss_channel);
|
||||
|
||||
let msg_id_key: Option<String> = redis.get(redis_key).await?;
|
||||
let cached_content: Option<String> = redis.get(content_key).await.unwrap_or(None);
|
||||
|
||||
if let Some(msg_id_key) = msg_id_key {
|
||||
if let Ok(msg_id) = msg_id_key.parse::<u64>() {
|
||||
if let Ok(mut message) = channel.message(http, msg_id).await {
|
||||
if let Some(existing) = message.embeds.first() {
|
||||
let new_description = existing.description.clone().unwrap();
|
||||
|
||||
if cached_content.as_deref() != Some(&new_description) {
|
||||
message.edit(http, EditMessage::new().embed(embed)).await?;
|
||||
}
|
||||
|
||||
if Regex::new(r"(?i)^Resolved\s*-").unwrap().is_match(&new_description) {
|
||||
redis.del(redis_key).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let message = channel.send_message(http, CreateMessage::new().add_embed(embed)).await?;
|
||||
redis.set(redis_key, &message.id.to_string()).await?;
|
||||
redis.expire(redis_key, 36000).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process the content string
|
||||
async fn process_msg_content(
|
||||
http: &Http,
|
||||
content: String,
|
||||
redis_key: &str
|
||||
) -> KonResult<()> {
|
||||
let redis = get_redis().await;
|
||||
let channel = ChannelId::new(BINARY_PROPERTIES.rss_channel);
|
||||
|
||||
let msg_id_key: Option<String> = redis.get(redis_key).await?;
|
||||
|
||||
if let Some(msg_id_key) = msg_id_key {
|
||||
if let Ok(msg_id) = msg_id_key.parse::<u64>() {
|
||||
channel.edit_message(http, msg_id, EditMessage::new().content(content)).await?;
|
||||
}
|
||||
} else {
|
||||
let message = channel.send_message(http, CreateMessage::new().content(content)).await?;
|
||||
redis.set(redis_key, &message.id.to_string()).await?;
|
||||
redis.expire(redis_key, 36000).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct RSSProcessor {
|
||||
pub feeds: Vec<RSSFeedBox>
|
||||
}
|
||||
|
||||
impl RSSProcessor {
|
||||
pub fn new() -> Self { Self { feeds: Vec::new() } }
|
||||
|
||||
pub fn add_feed(
|
||||
&mut self,
|
||||
feed: RSSFeedBox
|
||||
) {
|
||||
self.feeds.push(feed);
|
||||
}
|
||||
|
||||
pub async fn process_all(
|
||||
&self,
|
||||
ctx: Arc<Context>
|
||||
) -> KonResult<()> {
|
||||
let mut discord_msg: Vec<String> = Vec::new();
|
||||
|
||||
for feed in &self.feeds {
|
||||
let feed_name = feed.name();
|
||||
let redis_key = format!("RSS_{feed_name}_MsgId");
|
||||
let error_msg = format!("**[{TASK_NAME}:{feed_name}:Error]:** Feed failed with the following error:```\n{{ error }}\n```");
|
||||
|
||||
match feed.process(ctx.clone()).await {
|
||||
Ok(Some(output)) => match output {
|
||||
RSSFeedOutput::RegularEmbed(embed) => {
|
||||
if let Err(e) = process_regular_embed(&ctx.http, embed, &redis_key).await {
|
||||
discord_msg.push(error_msg.replace("{{ error }}", &e.to_string()))
|
||||
}
|
||||
},
|
||||
RSSFeedOutput::IncidentEmbed(embed) => {
|
||||
if let Err(e) = process_incident_embed(&ctx.http, embed, &redis_key, &format!("RSS_{feed_name}_Content")).await {
|
||||
discord_msg.push(error_msg.replace("{{ error }}", &e.to_string()))
|
||||
}
|
||||
},
|
||||
RSSFeedOutput::Content(content) => {
|
||||
if let Err(e) = process_msg_content(&ctx.http, content, &redis_key).await {
|
||||
discord_msg.push(error_msg.replace("{{ error }}", &e.to_string()))
|
||||
}
|
||||
},
|
||||
},
|
||||
Ok(None) => (),
|
||||
Err(e) => discord_msg.push(error_msg.replace("{{ error }}", &e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
if !discord_msg.is_empty() {
|
||||
ChannelId::new(BINARY_PROPERTIES.kon_logs)
|
||||
.send_message(&ctx.http, CreateMessage::new().content(discord_msg.join("\n")))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,94 +0,0 @@
|
||||
use super::{
|
||||
RSSFeed,
|
||||
RSSFeedOutput,
|
||||
fetch_feed,
|
||||
get_redis,
|
||||
parse,
|
||||
save_to_redis,
|
||||
task_err
|
||||
};
|
||||
|
||||
use {
|
||||
kon_libs::KonResult,
|
||||
poise::serenity_prelude::{
|
||||
Context,
|
||||
async_trait
|
||||
},
|
||||
regex::Regex,
|
||||
std::{
|
||||
io::Cursor,
|
||||
sync::Arc
|
||||
}
|
||||
};
|
||||
|
||||
pub struct RustBlog {
|
||||
url: String
|
||||
}
|
||||
|
||||
impl RustBlog {
|
||||
pub fn new(url: String) -> Self { Self { url } }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RSSFeed for RustBlog {
|
||||
fn name(&self) -> &str { "RustBlog" }
|
||||
|
||||
fn url(&self) -> &str { self.url.as_str() }
|
||||
|
||||
async fn process(
|
||||
&self,
|
||||
_ctx: Arc<Context>
|
||||
) -> KonResult<Option<RSSFeedOutput>> {
|
||||
let redis = get_redis().await;
|
||||
let rkey = "RSS_RustBlog";
|
||||
|
||||
let res = fetch_feed(self.url()).await?;
|
||||
let data = res.text().await?;
|
||||
let cursor = Cursor::new(data);
|
||||
|
||||
let feed = parse(cursor).map_err(|e| {
|
||||
task_err("RSS:RustBlog", &format!("Error parsing RSS feed: {e}"));
|
||||
e
|
||||
})?;
|
||||
|
||||
if feed.entries.is_empty() {
|
||||
task_err("RSS:RustBlog", "No entries found in the feed!");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let article = feed.entries[0].clone();
|
||||
let article_id = article.id.clone();
|
||||
|
||||
fn get_blog_title(input: String) -> Option<String> {
|
||||
let re = Regex::new(r"https://blog\.rust-lang\.org/(\d{4}/\d{2}/\d{2}/[^/]+)").unwrap();
|
||||
re.captures(input.as_str()).and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
|
||||
}
|
||||
|
||||
let cached_blog = redis.get(rkey).await.unwrap_or(None).unwrap_or_default();
|
||||
|
||||
if cached_blog.is_empty() {
|
||||
save_to_redis(rkey, &get_blog_title(article.id).unwrap()).await?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(blog_title) = get_blog_title(article.id) {
|
||||
if blog_title == cached_blog {
|
||||
Ok(None)
|
||||
} else {
|
||||
save_to_redis(rkey, &blog_title).await?;
|
||||
|
||||
Ok(Some(RSSFeedOutput::Content(format!(
|
||||
"Rust Team has put out a new article!\n**[{}](<{}>)**",
|
||||
article.links[0].title.clone().unwrap(),
|
||||
article.links[0].href
|
||||
))))
|
||||
}
|
||||
} else {
|
||||
task_err(
|
||||
"RSS:RustBlog",
|
||||
&format!("Article URL does not match the expected RegEx pattern! ({article_id})")
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
[package]
|
||||
name = "kon_tokens"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
tokenservice-client = { version = "0.4.1", registry = "gitea" }
|
||||
tokio = { workspace = true }
|
@ -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}")
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user