Fetching minecraft server status
This commit is contained in:
parent
62d902550e
commit
4226f7c143
|
@ -1,2 +1,5 @@
|
||||||
[env]
|
[env]
|
||||||
RUST_BACKTRACE = "1"
|
RUST_BACKTRACE = "1"
|
||||||
|
|
||||||
|
# [profile.release]
|
||||||
|
# RUSTFLAGS="-Zlocation-detail=none"
|
||||||
|
|
1205
Cargo.lock
generated
1205
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
12
Cargo.toml
12
Cargo.toml
|
@ -5,12 +5,22 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.82"
|
anyhow = "1.0.82"
|
||||||
|
axum = "0.7.5"
|
||||||
clap = "4.5.4"
|
clap = "4.5.4"
|
||||||
color-eyre = "0.6.3"
|
color-eyre = "0.6.3"
|
||||||
discord = { git = "https://github.com/SpaceManiac/discord-rs" }
|
discord = { git = "https://github.com/SpaceManiac/discord-rs" }
|
||||||
tokio = "1.37.0"
|
redact = { version = "0.1.9", features = ["serde"] }
|
||||||
|
reqwest = { version = "0.12.3", features = ["json", "socks"] }
|
||||||
|
reqwest-middleware = "0.3.0"
|
||||||
|
reqwest-retry = "0.5.0"
|
||||||
|
reqwest-tracing = "0.5.0"
|
||||||
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
|
serde_json = "1.0.115"
|
||||||
|
serde_with = "3.7.0"
|
||||||
|
tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread"] }
|
||||||
tracing = "0.1.40"
|
tracing = "0.1.40"
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||||
|
urlencoding = "2.1.3"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1
|
opt-level = 1
|
||||||
|
|
30
src/main.rs
30
src/main.rs
|
@ -1,33 +1,31 @@
|
||||||
mod config;
|
#![forbid(unsafe_code)]
|
||||||
mod prelude;
|
|
||||||
|
|
||||||
use tracing::level_filters::LevelFilter;
|
mod config;
|
||||||
use tracing_subscriber::EnvFilter;
|
mod minecraft_server_status;
|
||||||
|
mod observe;
|
||||||
|
mod prelude;
|
||||||
|
mod webclient;
|
||||||
|
mod webserver;
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
fn main() {
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
let (conf, conf_err) = Config::load_or_defaults();
|
let (conf, conf_err) = Config::load_or_defaults();
|
||||||
|
observe::setup_logging(&conf);
|
||||||
color_eyre::install().expect("Failed to install color_eyre");
|
|
||||||
|
|
||||||
let filter = EnvFilter::builder()
|
|
||||||
.with_default_directive(LevelFilter::TRACE.into())
|
|
||||||
.parse_lossy("info,chatbot=trace");
|
|
||||||
|
|
||||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
|
||||||
|
|
||||||
debug!("Configuration: {conf:?}");
|
debug!("Configuration: {conf:?}");
|
||||||
if let Some(err) = conf_err {
|
if let Some(err) = conf_err {
|
||||||
warn!("Error loading configuration: {err}");
|
warn!("Error loading configuration: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: tracing stuff
|
|
||||||
// TODO: load config
|
|
||||||
// TODO: oh yeah we need an HTTP server to handle minecraft server status stuff
|
// TODO: oh yeah we need an HTTP server to handle minecraft server status stuff
|
||||||
|
let server = webserver::start(&conf);
|
||||||
|
|
||||||
// TODO: family reminders?
|
// TODO: family reminders?
|
||||||
// TODO: connect to Discord
|
// TODO: connect to Discord
|
||||||
// TODO: handle messages
|
// TODO: handle messages
|
||||||
// TODO: data persistence? (sqlite? sled?)
|
// TODO: data persistence? (sqlite? sled?)
|
||||||
info!("Hello, world!");
|
|
||||||
|
server.await
|
||||||
}
|
}
|
||||||
|
|
109
src/minecraft_server_status.rs
Normal file
109
src/minecraft_server_status.rs
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
use crate::prelude::*;
|
||||||
|
use crate::webclient::Client;
|
||||||
|
use serde_with::skip_serializing_none;
|
||||||
|
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub struct MinecraftServerStatus {
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Player {
|
||||||
|
pub name: String,
|
||||||
|
pub uuid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct PlayerInfo {
|
||||||
|
pub max: u32,
|
||||||
|
pub online: u32,
|
||||||
|
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub list: Option<Vec<Player>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Plugin {
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Protocol {
|
||||||
|
pub name: String,
|
||||||
|
pub version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct DebugInfo {
|
||||||
|
pub ping: bool,
|
||||||
|
pub query: bool,
|
||||||
|
pub srv: bool,
|
||||||
|
pub querymismatch: bool,
|
||||||
|
pub ipinsrv: bool,
|
||||||
|
pub cnameinsrv: bool,
|
||||||
|
pub animatedmotd: bool,
|
||||||
|
pub cachehit: bool,
|
||||||
|
pub cachetime: u64,
|
||||||
|
pub cacheexpire: u64,
|
||||||
|
pub apiversion: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[skip_serializing_none]
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct ServerStatus {
|
||||||
|
pub online: bool,
|
||||||
|
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub ip: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub hostname: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub debug: Option<DebugInfo>,
|
||||||
|
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub protocol: Option<Protocol>,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub gamemode: Option<String>,
|
||||||
|
pub serverid: Option<String>,
|
||||||
|
pub eula_blocked: Option<bool>,
|
||||||
|
pub map: Option<HashMap<String, String>>,
|
||||||
|
pub motd: Option<HashMap<String, Vec<String>>>,
|
||||||
|
pub players: Option<PlayerInfo>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub plugins: Option<Vec<Plugin>>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub mods: Option<Vec<Plugin>>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub software: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub info: Option<HashMap<String, Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MinecraftServerStatus {
|
||||||
|
pub fn try_new() -> Result<Self> {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("content-type", HeaderValue::from_str("application/json")?);
|
||||||
|
headers.insert("accepts", HeaderValue::from_str("application/json")?);
|
||||||
|
let base_client = Client::builder().default_headers(headers).build()?;
|
||||||
|
let client = Client::try_new(base_client, "https://api.mcsrvstat.us/3")?;
|
||||||
|
Ok(Self { client })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(&self, endpoint: &str) -> Result<ServerStatus> {
|
||||||
|
self.client
|
||||||
|
.get(&format!("/{}", urlencoding::encode(endpoint)))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
14
src/observe.rs
Normal file
14
src/observe.rs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
use tracing::level_filters::LevelFilter;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
pub fn setup_logging(_conf: &Config) {
|
||||||
|
color_eyre::install().expect("Failed to install color_eyre");
|
||||||
|
|
||||||
|
let filter = EnvFilter::builder()
|
||||||
|
.with_default_directive(LevelFilter::TRACE.into())
|
||||||
|
.parse_lossy("trace,chatbot=trace");
|
||||||
|
|
||||||
|
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||||
|
}
|
|
@ -3,4 +3,7 @@
|
||||||
// pub use color_eyre;
|
// pub use color_eyre;
|
||||||
pub use crate::config::Config;
|
pub use crate::config::Config;
|
||||||
pub use anyhow::Error;
|
pub use anyhow::Error;
|
||||||
|
pub use anyhow::Result;
|
||||||
|
pub use redact::Secret;
|
||||||
|
pub use serde::{Deserialize, Serialize};
|
||||||
pub use tracing::{debug, error, info, trace, warn};
|
pub use tracing::{debug, error, info, trace, warn};
|
||||||
|
|
70
src/webclient.rs
Normal file
70
src/webclient.rs
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
use reqwest::{Client as RClient, Method, Url};
|
||||||
|
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, RequestBuilder};
|
||||||
|
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
|
||||||
|
use reqwest_tracing::TracingMiddleware;
|
||||||
|
use serde::de;
|
||||||
|
|
||||||
|
pub struct Client {
|
||||||
|
client: ClientWithMiddleware,
|
||||||
|
base_url: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ResourceRequest {
|
||||||
|
async fn res<T: de::DeserializeOwned>(self) -> Result<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResourceRequest for RequestBuilder {
|
||||||
|
async fn res<T: de::DeserializeOwned>(self) -> Result<T> {
|
||||||
|
let body = self.send().await?.text().await?;
|
||||||
|
debug!(body);
|
||||||
|
serde_json::from_str(&body).map_err(|e| e.into())
|
||||||
|
|
||||||
|
// self.send().await?.json().await.map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn builder() -> reqwest::ClientBuilder {
|
||||||
|
RClient::builder()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_new(client: RClient, base_url: &str) -> Result<Self> {
|
||||||
|
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
|
||||||
|
|
||||||
|
let client = ClientBuilder::new(client)
|
||||||
|
.with(TracingMiddleware::default())
|
||||||
|
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// force trailing slash
|
||||||
|
let mut base_url = Url::parse(base_url)?;
|
||||||
|
base_url.set_path(&format!("{}{}", base_url.path().trim_end_matches('/'), "/"));
|
||||||
|
|
||||||
|
Ok(Self { client, base_url })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes a path segment and properly joins it relative to `base_url`
|
||||||
|
pub fn url(&self, path: &str) -> Result<Url> {
|
||||||
|
self.base_url
|
||||||
|
.join(path.trim_start_matches('/'))
|
||||||
|
.map_err(|e| e.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(&self, method: Method, rel_url_path: &str) -> Result<RequestBuilder> {
|
||||||
|
Ok(self.client.request(method, self.url(rel_url_path)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn request<T: de::DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
rel_url_path: &str,
|
||||||
|
) -> Result<T> {
|
||||||
|
self.build(method, rel_url_path)?.res().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get<T: de::DeserializeOwned>(&self, rel_url_path: &str) -> Result<T> {
|
||||||
|
self.request::<T>(Method::GET, rel_url_path).await
|
||||||
|
}
|
||||||
|
}
|
67
src/webserver.rs
Normal file
67
src/webserver.rs
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
use crate::{minecraft_server_status::MinecraftServerStatus, prelude::*};
|
||||||
|
|
||||||
|
struct WebserverError(anyhow::Error);
|
||||||
|
type Result<T> = std::result::Result<T, WebserverError>;
|
||||||
|
|
||||||
|
pub async fn start(_conf: &Config) {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(hello_world))
|
||||||
|
.route("/minecraft-server-status", get(minecraft_server_status));
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||||
|
info!("Listening on {:?}", listener);
|
||||||
|
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hello_world() -> impl IntoResponse {
|
||||||
|
"Hello, World!".to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn minecraft_server_status() -> Result<impl IntoResponse> {
|
||||||
|
let c = Arc::new(MinecraftServerStatus::try_new()?);
|
||||||
|
let reqs: Vec<&str> = vec!["h.lyte.dev:26965", "ourcraft.lyte.dev"];
|
||||||
|
let mut futures = Vec::with_capacity(reqs.len());
|
||||||
|
let mut results = Vec::with_capacity(reqs.len());
|
||||||
|
for r in reqs {
|
||||||
|
let client = c.clone();
|
||||||
|
debug!("Fetching server status for jql {}", r);
|
||||||
|
futures.push(tokio::spawn(async move { client.status(r).await }));
|
||||||
|
}
|
||||||
|
|
||||||
|
for task in futures {
|
||||||
|
results.push(task.await??)
|
||||||
|
}
|
||||||
|
Ok(Json(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for WebserverError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
error!("WebserverError: {:?}", self.0);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Something went wrong: {}", self.0),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into
|
||||||
|
// `Result<_, AppError>`. That way you don't need to do that manually.
|
||||||
|
impl<E> From<E> for WebserverError
|
||||||
|
where
|
||||||
|
E: Into<anyhow::Error>,
|
||||||
|
{
|
||||||
|
fn from(err: E) -> Self {
|
||||||
|
Self(err.into())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue