Fetching minecraft server status
This commit is contained in:
parent
62d902550e
commit
4226f7c143
|
@ -1,2 +1,5 @@
|
|||
[env]
|
||||
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]
|
||||
anyhow = "1.0.82"
|
||||
axum = "0.7.5"
|
||||
clap = "4.5.4"
|
||||
color-eyre = "0.6.3"
|
||||
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-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
|
30
src/main.rs
30
src/main.rs
|
@ -1,33 +1,31 @@
|
|||
mod config;
|
||||
mod prelude;
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
mod config;
|
||||
mod minecraft_server_status;
|
||||
mod observe;
|
||||
mod prelude;
|
||||
mod webclient;
|
||||
mod webserver;
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
fn main() {
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let (conf, conf_err) = Config::load_or_defaults();
|
||||
|
||||
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();
|
||||
observe::setup_logging(&conf);
|
||||
|
||||
debug!("Configuration: {conf:?}");
|
||||
if let Some(err) = conf_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
|
||||
let server = webserver::start(&conf);
|
||||
|
||||
// TODO: family reminders?
|
||||
// TODO: connect to Discord
|
||||
// TODO: handle messages
|
||||
// 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 crate::config::Config;
|
||||
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};
|
||||
|
|
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