Merge pull request 'type-api-rewrite' (#1) from type-api-rewrite into main

Reviewed-on: #1
This commit is contained in:
August 2026-05-06 19:15:14 -04:00
commit 513b2c2b6d
14 changed files with 1197 additions and 1130 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
/target
.env
config.json

1606
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,14 @@
[package]
name = "playerbot"
version = "0.6.1"
version = "1.0.0"
edition = "2021"
[dependencies]
anyhow = "1.0.93"
dotenvy = "0.15.7"
log = "0.4.22"
poise = "0.6.1"
pretty_env_logger = "0.5.0"
reqwest = {version = "0.12.9", features = ["json"]}
anyhow = "1.0.102"
futures = "0.3.32"
poise = "0.6.2"
reqwest = {version = "0.13.3", features = ["json"]}
serde = {version = "1.0.215", features = ["derive", "serde_derive"]}
serde_json = {version = "1.0.132", features = []}
serde_json = "1.0.149"
tokio = {version = "1.41.1", features = ["full"]}
url = "2.5.3"

135
src/bot_runner.rs Normal file
View File

@ -0,0 +1,135 @@
use crate::request::request;
use crate::types::*;
use poise::serenity_prelude as serenity;
use poise::serenity_prelude::{ActivityData, ClientBuilder, GatewayIntents};
use tokio::sync::Mutex;
pub struct BotRunner {
client: poise::serenity_prelude::Client,
}
pub struct Data {
server: Box<dyn ServerInfo>,
cached_reply: Mutex<Option<ServerResponse>>,
}
impl BotRunner {
pub async fn new(server: Box<dyn ServerInfo>) -> Self {
let token = server.app_token();
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
commands: vec![players(), join()],
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 {
server,
cached_reply: Mutex::new(None),
})
})
})
.build();
let client = ClientBuilder::new(token, GatewayIntents::non_privileged())
.framework(framework)
.activity(ActivityData::custom("Waiting on first heartbeat"))
.await
.unwrap();
BotRunner { client }
}
pub async fn run(&mut self) -> Result<(), serenity::Error> {
self.client.start().await
}
}
pub async fn event_handler(
ctx: &serenity::Context,
event: &serenity::FullEvent,
_framework: poise::FrameworkContext<'_, Data, serenity::Error>,
data: &Data,
) -> Result<(), serenity::Error> {
match event {
serenity::FullEvent::Ready {
data_about_bot: _bot_data,
} => loop {
println!("Checking status: {}", data.server.api_address());
let http_response = request(data.server.api_address()).await.unwrap();
let results = data.server.parse(http_response.text().await.unwrap());
match &results {
ServerResponse::Offline => {
ctx.set_presence(
Some(ActivityData::custom("Server offline!")),
serenity::OnlineStatus::DoNotDisturb,
);
}
ServerResponse::Online(online_info) => {
ctx.set_presence(
Some(ActivityData::custom(format!(
"{}/{} online, v{}",
online_info.players_online,
online_info.player_limit,
online_info.version
))),
if online_info.players_online > 0 {
serenity::OnlineStatus::Online
} else {
serenity::OnlineStatus::Idle
},
);
}
}
//println!("{:#?}", &results);
*data.cached_reply.lock().await = Some(results);
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
},
_ => Ok(()),
}
}
#[poise::command(slash_command)]
pub async fn players(
ctx: poise::Context<'_, Data, serenity::Error>,
) -> Result<(), serenity::Error> {
if let Some(reply) = &*ctx.data().cached_reply.lock().await {
match reply {
ServerResponse::Offline => ctx.say("Server is offline!").await?,
ServerResponse::Online(on) => {
if let Some(list) = &on.players {
ctx.say(format!(
"{} out of {} players are online.\nPlayers online: {}",
on.players_online,
on.player_limit,
list.join(", ")
))
.await?
} else {
ctx.say(format!(
"{} out of {} players are online.",
on.players_online, on.player_limit
))
.await?
}
}
};
} else {
ctx.say("No status response yet. Please wait 30 seconds.")
.await?;
}
Ok(())
}
#[poise::command(slash_command)]
pub async fn join(
ctx: poise::Context<'_, Data, serenity::Error>,
) -> Result<(), serenity::Error> {
ctx.say(format!("To join, type `{}` in the server URL box or search bar.", ctx.data().server.addressable_name())).await?;
Ok(())
}

37
src/config_parser.rs Normal file
View File

@ -0,0 +1,37 @@
use crate::{handlers, types::ServerInfo};
use serde::Deserialize;
use std::{fs::File, path::Path};
use url::Url;
#[derive(Deserialize)]
pub struct ConfigEntry {
handler_type: String,
address: Url,
token: String,
}
#[derive(Deserialize)]
pub struct Config {
version: String,
entries: Vec<ConfigEntry>,
}
pub fn parse_configs(path: &Path) -> Result<Config, anyhow::Error> {
let file_handle = File::open(path)?;
Ok(serde_json::from_reader::<File, Config>(file_handle)?)
}
pub fn build_handlers(conf: Config) -> Vec<Box<dyn ServerInfo>> {
assert_eq!(conf.version, "0.1.0");
let mut results: Vec<Box<dyn ServerInfo>> = vec![];
for item in conf.entries {
match item.handler_type.as_str() {
"minecraft" => results.push(Box::new(handlers::minecraft::Server::new(
item.token,
item.address,
))),
_ => {}
}
}
results
}

View File

@ -1,30 +0,0 @@
use poise::serenity_prelude::{ActivityData, OnlineStatus};
use crate::types::ServerResponse;
pub fn set_presence(ctx: &poise::serenity_prelude::Context, status: ServerResponse) {
ctx.set_presence(
Some(ActivityData::custom(match status.online() {
true => {
format!(
"{}/{} players online {}",
status.players().unwrap(),
status.max().unwrap(),
if let Some(version) = status.version() {
format!("v{}", version)
} else {
"".into()
}
)
}
false => "Server offline!".to_string(),
})),
match status.online() {
true => match status.is_full() {
true => OnlineStatus::Idle,
false => OnlineStatus::Online,
},
false => OnlineStatus::DoNotDisturb,
},
);
}

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

@ -0,0 +1,104 @@
use serde::Deserialize;
use serde_json;
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)]
struct ApiResponsePlayers {
online: u64,
max: u64,
list: Option<Vec<ApiResponsePlayerEntry>>,
}
#[derive(Deserialize, Debug, Clone)]
struct ApiResponsePlayerEntry {
name: String,
#[allow(dead_code)]
uuid: String,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)]
struct Motd {
raw: Vec<String>,
clean: Vec<String>,
html: Vec<String>,
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct OnlineResponse {
searchable_name: String,
reported_name: String,
clean_name: String,
players_online: u64,
player_limit: u64,
version: String,
players: Vec<String>,
}
pub struct Server {
#[allow(dead_code)]
token: String,
addr: Url,
}
impl ServerInfo for Server {
fn new(token: String, addr: Url) -> Self {
Server { token, addr }
}
fn addressable_name(&self) -> String {
self.addr.clone().into()
}
fn app_token(&self) -> String {
self.token.clone()
}
fn api_address(&self) -> Url {
let url_string = format!("https://api.mcsrvstat.us/3/{}", self.addr.host().unwrap());
Url::parse(&url_string).unwrap()
}
fn parse(&self, response: String) -> ServerResponse {
let parsed_data: ApiResponse = serde_json::from_str(&response).unwrap();
match parsed_data.online {
true => {
let players = parsed_data.players.unwrap();
let motd = parsed_data.motd.as_ref().unwrap();
self::ServerResponse::Online(ServerOnlineResponse {
searchable_name: self.addr.host().unwrap().to_string(),
readable_name: motd.clean.first().unwrap().into(),
reported_name: motd.clean.first().unwrap().into(),
clean_name: motd.clean.first().unwrap().into(),
players_online: players.online,
player_limit: players.max,
version: parsed_data.version.unwrap(),
players: Some(
players
.list
.unwrap_or_default()
.iter()
.map(|e| e.name.clone())
.collect(),
),
})
}
false => ServerResponse::Offline,
}
}
fn supports_playerlist(&self) -> bool {
true
}
}

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,22 @@
mod funcs;
mod minecraft;
mod scpsl;
#![feature(impl_trait_in_bindings)]
mod bot_runner;
mod config_parser;
mod handlers;
mod request;
mod types;
use dotenvy::{self, dotenv};
use minecraft::Minecraft;
use tokio::join;
use url::{self, Url};
#[macro_use]
extern crate log;
use crate::bot_runner::BotRunner;
use futures::{self, future::try_join_all};
use std::path::Path;
#[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(),
);
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 config = config_parser::parse_configs(Path::new("config.json")).unwrap();
let handlers = config_parser::build_handlers(config);
let mut bots: Vec<bot_runner::BotRunner> = vec![];
for item in handlers {
bots.push(BotRunner::new(item).await);
}
let futures: Vec<_> = bots.iter_mut().map(|b| b.run()).collect();
let _ = try_join_all(futures).await;
}

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(()),
}
}

9
src/request.rs Normal file
View File

@ -0,0 +1,9 @@
use reqwest::{header::HeaderValue, Response};
use url::Url;
pub async fn request(url: Url) -> Result<Response, reqwest::Error> {
let client = reqwest::Client::new();
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());
client.execute(request).await
}

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,40 @@
use std::fmt::format;
use url::Url;
pub struct ServerResponse {
online: bool,
players: Option<u32>,
max: Option<u32>,
version: Option<String>,
#[derive(Debug)]
pub enum ServerResponse {
Offline,
Online(ServerOnlineResponse),
}
impl ServerResponse {
pub fn new(
online: bool,
players: Option<u32>,
max: Option<u32>,
version: Option<String>,
) -> Self {
ServerResponse {
online,
players,
max,
version,
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct ServerOnlineResponse {
pub players_online: u64,
pub player_limit: u64,
pub version: String,
pub searchable_name: String,
pub readable_name: String,
pub clean_name: String,
pub reported_name: String,
pub players: Option<Vec<String>>,
}
pub fn online(&self) -> bool {
self.online
}
#[allow(dead_code)]
pub trait ServerInfo: Send + Sync {
fn new(token: String, addr: Url) -> Self
where
Self: Sized;
pub fn players(&self) -> Option<u32> {
self.players
}
/// 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.
fn addressable_name(&self) -> String;
/// The address wrapped in the API URL.
fn api_address(&self) -> Url;
fn parse(&self, response: String) -> ServerResponse;
pub fn max(&self) -> Option<u32> {
self.max
}
/// The app token used to send status updates.
fn app_token(&self) -> String;
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()
}
)
}
fn supports_playerlist(&self) -> bool;
}