Initial rewrite

This commit is contained in:
August 2026-05-06 00:48:30 -04:00
parent e5850f8b89
commit 46485ad396
Signed by: shibedrill
SSH Key Fingerprint: SHA256:M0m3JW1s38BgO2t0fG146Yxd9OJ2IOqkvCAsuRHQ6Pw
7 changed files with 148 additions and 333 deletions

108
src/handlers/minecraft.rs Normal file
View File

@ -0,0 +1,108 @@
use reqwest::{header::HeaderValue, Client};
use serde::Deserialize;
use url::Url;
use crate::types::{ServerInfo, ServerOnlineResponse, ServerResponse};
#[derive(Deserialize, Debug)]
struct ApiResponse {
online: bool,
version: Option<String>,
players: Option<ApiResponsePlayers>,
motd: Option<Motd>,
}
#[derive(Deserialize, Debug, Clone, Copy)]
struct ApiResponsePlayers {
online: u64,
max: u64,
}
#[derive(Deserialize, Debug)]
struct Motd {
raw: Vec<String>,
clean: Vec<String>,
html: Vec<String>,
}
#[derive(Debug)]
pub struct OnlineResponse {
searchable_name: String,
reported_name: String,
clean_name: String,
players_online: u64,
player_limit: u64,
version: String,
}
impl ServerOnlineResponse for OnlineResponse {
fn players_online(&self) -> u64 {
self.players_online
}
fn player_limit(&self) -> u64 {
self.player_limit
}
fn version(&self) -> &String {
&self.version
}
}
pub struct Server {
token: String,
addr: Url,
}
impl ServerInfo for Server {
fn new(token: String, addr: Url) -> Self {
Server { token, addr }
}
type OnlineResponse = OnlineResponse;
type AddressableName = Url;
fn addressable_name(&self) -> &Self::AddressableName {
&self.addr
}
fn app_token(&self) -> String {
self.token.clone()
}
async fn poll(&self) -> ServerResponse<Self::OnlineResponse> {
use reqwest;
let url_string = format!("https://api.mcsrvstat.us/3/{}", self.addr.host().unwrap());
let url = Url::try_from(url_string.as_str()).unwrap();
let mut request = reqwest::Request::new(reqwest::Method::GET, url);
request.headers_mut().append("User-Agent", HeaderValue::from_str("Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36").unwrap());
let client = Client::new();
match client.execute(request).await {
Err(error) => ServerResponse::Error(anyhow::anyhow!(error)),
Ok(answer) => {
let json = answer.json::<ApiResponse>().await.unwrap();
//println!("{}", answer.text().await.unwrap());
//ServerResponse::Offline
match json.online {
true => {
let motd = json.motd.as_ref().unwrap();
self::ServerResponse::Online(OnlineResponse {
searchable_name: motd.clean.get(0).unwrap().into(),
reported_name: motd.clean.get(0).unwrap().into(),
clean_name: motd.clean.get(0).unwrap().into(),
players_online: json.players.unwrap().online,
player_limit: json.players.unwrap().max,
version: json.version.unwrap(),
})
}
false => ServerResponse::Offline,
}
}
}
}
}

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

@ -0,0 +1,2 @@
pub mod minecraft;
pub mod scpsl;

1
src/handlers/scpsl.rs Normal file
View File

@ -0,0 +1 @@

View File

@ -1,33 +1,17 @@
mod funcs;
mod minecraft;
mod scpsl;
mod handlers;
mod types;
use dotenvy::{self, dotenv};
use minecraft::Minecraft;
use tokio::join;
use url::{self, Url};
#[macro_use]
extern crate log;
use crate::{handlers::minecraft, types::ServerInfo};
use tokio;
#[tokio::main]
async fn main() {
dotenv().ok();
pretty_env_logger::init();
let gamerzone = Minecraft::new(
Url::try_from("https://api.mcstatus.io/v2/status/java/bleat.shibedrill.site").unwrap(),
std::env::var("TOKEN_BOT_MC_BLEAT").unwrap(),
"DeersCord SMP".into(),
println!("Hello world!");
let mc = minecraft::Server::new(
"foo".into(),
"http://dawn.shibedrill.site".try_into().unwrap(),
);
let mchprs = Minecraft::new(
Url::try_from("https://api.mcstatus.io/v2/status/java/mchprs.shibedrill.site").unwrap(),
std::env::var("TOKEN_BOT_MC_MCHPRS").unwrap(),
"Project MCRV".into(),
);
let dawn = Minecraft::new(
Url::try_from("https://api.mcstatus.io/v2/status/java/dawn.shibedrill.site").unwrap(),
std::env::var("TOKEN_BOT_MC_DAWN").unwrap(),
"Dawn Group".into(),
);
join!(mchprs.run(), gamerzone.run(), dawn.run());
let results = mc.poll().await;
println!("{:#?}", results);
}

View File

@ -1,131 +0,0 @@
use poise::serenity_prelude as serenity;
use reqwest::{Client, Request};
use serenity::*;
use url::Url;
use crate::{
funcs,
types::{self, ServerResponse},
};
#[derive(serde::Deserialize, Debug)]
struct ServerSummary {
online: bool,
players: Option<Players>,
version: Version,
}
#[derive(serde::Deserialize, Debug)]
struct Version {
name_raw: String,
name_clean: String,
name_html: String,
protocol: u32,
}
#[derive(serde::Deserialize, Debug)]
struct Players {
online: i32,
max: i32,
}
pub struct Data {
controller: Minecraft,
}
#[derive(Clone)]
pub struct Minecraft {
url: Url,
token: String,
name: String,
}
impl Minecraft {
pub fn new(url: Url, token: String, name: String) -> Self {
Self { url, token, name }
}
pub async fn get_status(&self) -> Result<ServerResponse, anyhow::Error> {
let http_client = Client::new();
trace!("Created HTTP client");
let request = Request::new(reqwest::Method::GET, self.url.clone());
trace!("Created HTTP request");
let response = http_client.execute(request).await?;
trace!("Ran request using client");
let data: ServerSummary = serde_json::from_str(&response.text().await?)?;
trace!("Response JSON for {}: {:#?}", self.name, data); // this
if let Some(players) = data.players {
Ok(ServerResponse::new(
data.online,
Some(players.online as u32),
Some(players.max as u32),
Some(data.version.name_raw),
))
} else {
Ok(ServerResponse::new(data.online, None, None, None))
}
}
pub async fn run(&self) {
let controller = self.clone();
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
event_handler: |ctx, event, framework, data| {
Box::pin(event_handler(ctx, event, framework, data))
},
..Default::default()
})
.setup(|ctx, _ready, framework| {
Box::pin(async move {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
Ok(Data { controller })
})
})
.build();
info!("Built framework successfully.");
let mut discord_client = ClientBuilder::new(
self.token.clone(),
serenity::GatewayIntents::non_privileged(),
)
.framework(framework)
.activity(ActivityData::custom("Waiting on initial status..."))
.await
.inspect_err(|e| error!("Failed to start client: {}", e))
.unwrap();
info!("Built client successfully.");
let _ = discord_client.start().await;
}
}
pub async fn event_handler(
ctx: &serenity::Context,
event: &serenity::FullEvent,
_framework: poise::FrameworkContext<'_, Data, Error>,
data: &Data,
) -> Result<(), Error> {
match event {
serenity::FullEvent::Ready {
data_about_bot: _bot_data,
} => loop {
trace!("Running loop routine");
let status = data
.controller
.get_status()
.await
.inspect_err(|e| error!("{}: Failed to get status: {}", data.controller.name, e))
.unwrap();
info!(
"{}: Got status: {}",
data.controller.name,
status.to_string()
);
funcs::set_presence(ctx, status);
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
},
_ => Ok(()),
}
}

View File

@ -1,115 +0,0 @@
use crate::{funcs, types::ServerResponse};
use poise::serenity_prelude as serenity;
use reqwest::{Client, Request};
use serenity::*;
use url::Url;
#[derive(serde::Deserialize, Debug)]
#[allow(non_snake_case)]
struct ServerSummary {
online: bool,
players: String,
}
pub struct Data {
controller: Scpsl,
}
#[derive(Clone)]
pub struct Scpsl {
name: String,
url: Url,
token: String,
}
impl Scpsl {
pub fn new(url: Url, token: String, name: String) -> Self {
Self { url, token, name }
}
async fn get_status(&self) -> Result<ServerResponse, anyhow::Error> {
let http_client = Client::new();
trace!("Created HTTP client");
let request = Request::new(reqwest::Method::GET, self.url.clone());
trace!("Created HTTP request");
let response = http_client.execute(request).await?;
trace!("Ran request using client");
let data: ServerSummary = serde_json::from_str(&response.text().await?)?;
trace!("Response JSON for {}: {:#?}", self.name, data); // this is the only thing I added.
let playercount: Result<Vec<u32>, _> =
data.players.split('/').map(|x| x.parse::<u32>()).collect();
let playercount_unwrapped = playercount?;
Ok(ServerResponse::new(
data.online,
playercount_unwrapped.first().copied(),
playercount_unwrapped.get(1).copied(),
None,
))
}
pub async fn run(&self) {
let controller = self.clone();
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
initialize_owners: true,
event_handler: |ctx, event, framework, data| {
Box::pin(event_handler(ctx, event, framework, data))
},
..Default::default()
})
.setup(|ctx, _ready, framework| {
Box::pin(async move {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
Ok(Data { controller })
})
})
.build();
info!("Built framework successfully.");
let mut discord_client = ClientBuilder::new(
self.token.clone(),
serenity::GatewayIntents::non_privileged(),
)
.framework(framework)
.activity(ActivityData::custom("Waiting on initial status..."))
.await
.inspect_err(|e| error!("Failed to start client: {}", e))
.unwrap();
info!("Built client successfully.");
let _ = discord_client.start().await;
}
}
pub async fn event_handler(
ctx: &serenity::Context,
event: &serenity::FullEvent,
_framework: poise::FrameworkContext<'_, Data, Error>,
data: &Data,
) -> Result<(), Error> {
match event {
serenity::FullEvent::Ready {
data_about_bot: _data,
} => loop {
trace!("Running loop routine");
let status = data
.controller
.get_status()
.await
.inspect_err(|e| error!("{}: Failed to get status: {}", data.controller.name, e))
.unwrap();
info!(
"{}: Got status: {}",
data.controller.name,
status.to_string()
);
funcs::set_presence(ctx, status);
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
},
_ => Ok(()),
}
}

View File

@ -1,65 +1,31 @@
use std::fmt::format;
use std::net::IpAddr;
use url::Url;
pub struct ServerResponse {
online: bool,
players: Option<u32>,
max: Option<u32>,
version: Option<String>,
#[derive(Debug)]
pub enum ServerResponse<T: ServerOnlineResponse> {
Offline,
Online(T),
Error(anyhow::Error),
}
impl ServerResponse {
pub fn new(
online: bool,
players: Option<u32>,
max: Option<u32>,
version: Option<String>,
) -> Self {
ServerResponse {
online,
players,
max,
version,
}
}
pub fn online(&self) -> bool {
self.online
}
pub fn players(&self) -> Option<u32> {
self.players
}
pub fn max(&self) -> Option<u32> {
self.max
}
pub fn is_full(&self) -> bool {
self.players >= self.max
}
pub fn version(&self) -> &Option<String> {
&self.version
}
pub fn to_string(&self) -> String {
format!(
"{} {} {}",
if let Some(players) = self.players {
if let Some(max) = self.max {
format!("{}/{}", players, max)
} else {
players.to_string()
}
} else {
"N/A".into()
},
self.online,
if let Some(version) = &self.version {
version.to_string()
} else {
"N/A".into()
}
)
}
pub trait ServerOnlineResponse {
fn players_online(&self) -> u64;
fn player_limit(&self) -> u64;
fn version(&self) -> &String;
}
pub trait ServerInfo {
fn new(token: String, addr: Url) -> Self;
/// An addressable, unique identifier, used for its API.
/// If it is a Minecraft server, this can be a fully qualified domain name or URL.
/// If this is an SCP secret laboratory server, it can be a server ID.
type AddressableName;
fn addressable_name(&self) -> &Self::AddressableName;
/// The app token used to send status updates.
fn app_token(&self) -> String;
type OnlineResponse: ServerOnlineResponse;
async fn poll(&self) -> ServerResponse<Self::OnlineResponse>;
}