Slowly getting more usable
This commit is contained in:
parent
3455f323fc
commit
cbb93dd2e0
|
@ -3,5 +3,8 @@
|
|||
nixCargoIntegration.url = "github:yusdacra/nix-cargo-integration";
|
||||
};
|
||||
|
||||
outputs = inputs: inputs.nixCargoIntegration.lib.makeOutputs { root = ./.; };
|
||||
outputs = inputs:
|
||||
(inputs.nixCargoIntegration.lib.makeOutputs { root = ./.; }) // {
|
||||
nixosModules.glados = { imports = [ ./glados-module.nix ]; };
|
||||
};
|
||||
}
|
||||
|
|
112
glados-module.nix
Normal file
112
glados-module.nix
Normal file
|
@ -0,0 +1,112 @@
|
|||
{ pkgs, lib, config, ... }:
|
||||
|
||||
let
|
||||
cfg = config.services.glados;
|
||||
ownerArgs = list: toString (map (id: "--owner ${id}") list);
|
||||
in {
|
||||
options.services.glados = {
|
||||
enable = lib.mkEnableOption "GLaDOS systemd service";
|
||||
|
||||
discord = {
|
||||
guildId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Discord guild ID";
|
||||
example = "000000000000000000";
|
||||
};
|
||||
|
||||
ownerIds = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
description = "Discord guild owner IDs";
|
||||
example = "[ 000000000000000000 ]";
|
||||
};
|
||||
|
||||
adminChannel = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Discord Admin channel ID";
|
||||
example = "000000000000000000";
|
||||
};
|
||||
|
||||
infoChannel = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Discord Info channel ID";
|
||||
example = "000000000000000000";
|
||||
};
|
||||
|
||||
botToken = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Discord bot token";
|
||||
example = "AAAAAAAAAAAAAAAAAAAAAAAA.AAAAAA.AAAAAAAAAAAAAAAAAAA-AAAAAAA";
|
||||
};
|
||||
|
||||
camRole = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Role used for Minecraft cam accounts";
|
||||
example = "000000000000000000";
|
||||
};
|
||||
};
|
||||
|
||||
influx = {
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Influx db host url";
|
||||
example = "localhost:8000";
|
||||
};
|
||||
|
||||
database = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Influx database to store time series in";
|
||||
example = "minecraft";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Influx database user";
|
||||
example = "minecraft";
|
||||
};
|
||||
|
||||
password = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Influx database user password";
|
||||
example = "minecraft";
|
||||
};
|
||||
};
|
||||
|
||||
rcon = {
|
||||
address = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "RCON address";
|
||||
example = "your-host.com:12345";
|
||||
};
|
||||
|
||||
password = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "RCON password";
|
||||
example = "your-secret-password";
|
||||
};
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
systemd.services.glados = {
|
||||
wantedBy = [ "default.target" ];
|
||||
serviceConfig = {
|
||||
ExecStart = ''
|
||||
${pkgs.glados}/bin/glados
|
||||
--guild-id ${cfg.discord.guildId}
|
||||
${ownerArgs cfg.discord.ownerIds}
|
||||
--admin-channel ${cfg.discord.adminChannel}
|
||||
--info-channel ${cfg.discord.infoChannel}
|
||||
--discord-token ${cfg.discord.botToken}
|
||||
--cam-group-name ${cfg.discord.camRole}
|
||||
--influx-host ${cfg.influx.host}
|
||||
--influx-db ${cfg.influx.database}
|
||||
--influx-user ${cfg.influx.user}
|
||||
--influx-password ${cfg.influx.password}
|
||||
--rcon-address ${cfg.rcon.address}
|
||||
--rcon-password ${cfg.rcon.password}
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
|
@ -77,6 +77,9 @@ pub struct Args {
|
|||
|
||||
#[structopt(long, value_name = "PW", env = "INFLUX_PW", hide_env_values = true)]
|
||||
pub influx_password: String,
|
||||
|
||||
#[structopt(long, value_name = "NAME", env = "MC_CAM_GROUP")]
|
||||
pub cam_group_name: String,
|
||||
}
|
||||
|
||||
fn validate_token(raw: String) -> std::result::Result<(), String> {
|
||||
|
|
|
@ -22,6 +22,8 @@ pub enum Error {
|
|||
RconTpsCmd(#[source] nom::Err<nom::error::Error<String>>),
|
||||
#[error("Parsing results of `list` command: {_0}")]
|
||||
RconListCmd(#[source] nom::Err<nom::error::Error<String>>),
|
||||
#[error("Parsing results of `whois` command: {_0}")]
|
||||
RconWhoIsCmd(#[source] nom::Err<nom::error::Error<String>>),
|
||||
}
|
||||
|
||||
pub trait ResultExt<T> {
|
||||
|
|
|
@ -14,6 +14,8 @@ mod tracking;
|
|||
use discord::Bot;
|
||||
use error::Result;
|
||||
|
||||
type UUID<'s> = &'s str;
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
match real_main().await {
|
||||
|
|
38
src/rcon.rs
38
src/rcon.rs
|
@ -2,6 +2,10 @@ use std::time::Duration;
|
|||
|
||||
use serenity::client::Context;
|
||||
|
||||
mod commands;
|
||||
|
||||
pub use commands::*;
|
||||
|
||||
use crate::{
|
||||
conf::ARGS,
|
||||
data,
|
||||
|
@ -29,19 +33,41 @@ impl RconHandle {
|
|||
where
|
||||
C: RconCommand,
|
||||
{
|
||||
let cmd_str = cmd.as_string();
|
||||
let mut rcon = ctx.data.write().await;
|
||||
let rcon = rcon.get_mut::<data::Rcon>().expect("BUG: No rcon client");
|
||||
rcon.reconnect_until_success().await;
|
||||
let cmd_str = cmd.as_string();
|
||||
let raw_output = rcon
|
||||
.inner
|
||||
let result = rcon.execute_raw_command(&cmd_str).await;
|
||||
let raw_output = match result {
|
||||
Ok(raw_output) => raw_output,
|
||||
Err(why) => {
|
||||
match why {
|
||||
rcon::Error::Io(_) => {
|
||||
// Reset the connection and retry
|
||||
tracing::warn!("IO error while executing RCON command, retrying");
|
||||
rcon.inner = None;
|
||||
rcon.reconnect_until_success().await;
|
||||
rcon.execute_raw_command(&cmd_str)
|
||||
.await
|
||||
.map_err(|why| Error::RconCommand(cmd_str, why))?
|
||||
}
|
||||
why => return Err(Error::RconCommand(cmd_str, why)),
|
||||
}
|
||||
}
|
||||
};
|
||||
cmd.parse(raw_output)
|
||||
}
|
||||
|
||||
async fn execute_raw_command(
|
||||
&mut self,
|
||||
cmd: &str,
|
||||
) -> ::std::result::Result<String, rcon::Error> {
|
||||
self.inner
|
||||
.as_mut()
|
||||
// We must be connected here
|
||||
.unwrap()
|
||||
.cmd(&cmd_str)
|
||||
.cmd(&cmd)
|
||||
.await
|
||||
.map_err(|why| Error::RconCommand(cmd_str, why))?;
|
||||
cmd.parse(raw_output)
|
||||
}
|
||||
|
||||
async fn reconnect_until_success(&mut self) {
|
||||
|
|
5
src/rcon/commands.rs
Normal file
5
src/rcon/commands.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod luckperms_info;
|
||||
mod whois;
|
||||
|
||||
pub use luckperms_info::LuckPermsParentOfCmd;
|
||||
pub use whois::{WhoIs, WhoIsCmd};
|
18
src/rcon/commands/luckperms_info.rs
Normal file
18
src/rcon/commands/luckperms_info.rs
Normal file
|
@ -0,0 +1,18 @@
|
|||
use crate::{error::Result, rcon::RconCommand, UUID};
|
||||
|
||||
type ParentGroup = String;
|
||||
|
||||
pub struct LuckPermsParentOfCmd<'s>(pub UUID<'s>);
|
||||
|
||||
impl RconCommand for LuckPermsParentOfCmd<'_> {
|
||||
type Output = ParentGroup;
|
||||
|
||||
fn as_string(&self) -> String {
|
||||
format!("lp user {} parent info", self.0)
|
||||
}
|
||||
|
||||
fn parse(&self, raw: String) -> Result<Self::Output> {
|
||||
// TODO: Currently broken, lp commands don't work via rcon
|
||||
Ok(raw)
|
||||
}
|
||||
}
|
99
src/rcon/commands/whois.rs
Normal file
99
src/rcon/commands/whois.rs
Normal file
|
@ -0,0 +1,99 @@
|
|||
use nom::{
|
||||
branch::alt,
|
||||
bytes::complete::{is_not, tag},
|
||||
combinator::{map, value},
|
||||
sequence::{preceded, tuple},
|
||||
IResult, Parser,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
rcon::RconCommand,
|
||||
};
|
||||
|
||||
pub struct WhoIsCmd<'s>(pub &'s str);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub struct WhoIs {
|
||||
pub nick: String,
|
||||
pub uuid: String,
|
||||
pub afk: bool,
|
||||
}
|
||||
|
||||
impl RconCommand for WhoIsCmd<'_> {
|
||||
type Output = WhoIs;
|
||||
|
||||
fn as_string(&self) -> String {
|
||||
format!("whois {}", self.0)
|
||||
}
|
||||
|
||||
fn parse(&self, raw: String) -> Result<Self::Output> {
|
||||
parse_whois_result(&raw)
|
||||
.map(|(_, whois)| whois)
|
||||
.map_err(|why| Error::RconWhoIsCmd(why.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
// §6 ====== WhoIs:§c gloom_17 §6======\n§6 - Nick:§r gloom_17\n§6 - UUID:§r 1fea9fa1-7ee0-4c5f-b4d8-97185709cf33\n§6 - Health:§r 18.56/20\n§6 - Hunger:§r 16/20 (+0 saturation)\n§6 - Exp:§r 1,545 (Level 31)\n§6 - Location:§r (world, 443, 64, 73)\n§6 - Playtime:§r 2 days 2 hours 32 minutes\n§6 - Money:§r $0\n§6 - IP Address:§r /213.142.97.187\n§6 - Gamemode:§r survival\n§6 - God mode:§r §4false§r\n§6 - OP:§r §4false§r\n§6 - Fly mode:§r §4false§r (not flying)\n§6 - Speed:§r 0.2\n§6 - AFK:§r §4false§r\n§6 - Jail:§r §4false§r\n§6 - Muted:§r §4false§r\n
|
||||
|
||||
pub fn parse_whois_result(inp: &str) -> IResult<&str, WhoIs> {
|
||||
let list = tuple((
|
||||
prop("Nick", parse_string),
|
||||
prop("UUID", parse_string),
|
||||
prop("Health", ignore),
|
||||
prop("Hunger", ignore),
|
||||
prop("Exp", ignore),
|
||||
prop("Location", ignore),
|
||||
prop("Playtime", ignore),
|
||||
prop("Money", ignore),
|
||||
prop("IP Address", ignore),
|
||||
prop("Gamemode", ignore),
|
||||
prop("God mode", ignore),
|
||||
prop("OP", ignore),
|
||||
prop("Fly mode", ignore),
|
||||
prop("Speed", ignore),
|
||||
prop("AFK", parse_bool),
|
||||
prop("Jail", ignore),
|
||||
prop("Muted", ignore),
|
||||
));
|
||||
let parser = preceded(header, list);
|
||||
map(
|
||||
parser,
|
||||
|(nick, uuid, (), (), (), (), (), (), (), (), (), (), (), (), afk, (), ())| WhoIs {
|
||||
nick,
|
||||
uuid,
|
||||
afk,
|
||||
},
|
||||
)(inp)
|
||||
}
|
||||
|
||||
pub fn header(inp: &str) -> IResult<&str, ()> {
|
||||
value(
|
||||
(),
|
||||
tuple((tag("§6 ====== WhoIs:§c "), is_not(" "), tag(" §6======\n"))),
|
||||
)(inp)
|
||||
}
|
||||
|
||||
pub fn prop<'i, P, O, E>(name: &'i str, parser: P) -> impl Parser<&'i str, O, E>
|
||||
where
|
||||
P: Parser<&'i str, O, E>,
|
||||
E: nom::error::ParseError<&'i str>,
|
||||
{
|
||||
map(
|
||||
tuple((tag("§6 - "), tag(name), tag(":§r "), parser, tag("\n"))),
|
||||
|(_, _, _, res, _)| res,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_string(inp: &str) -> IResult<&str, String> {
|
||||
map(is_not("\n"), |s: &str| s.to_owned())(inp)
|
||||
}
|
||||
|
||||
pub fn ignore(inp: &str) -> IResult<&str, ()> {
|
||||
value((), is_not("\n"))(inp)
|
||||
}
|
||||
|
||||
pub fn parse_bool(inp: &str) -> IResult<&str, bool> {
|
||||
alt((value(true, tag("§4true§r")), value(false, tag("§4false§r"))))(inp)
|
||||
}
|
63
src/state.rs
63
src/state.rs
|
@ -8,12 +8,15 @@ use crate::{
|
|||
conf::ARGS,
|
||||
data,
|
||||
error::{Error, Result, ResultExt},
|
||||
rcon::{LuckPermsParentOfCmd, RconHandle, WhoIsCmd},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct State {
|
||||
/// Keeps track of the current guild members
|
||||
pub guild_members: HashSet<GuildMember>,
|
||||
/// Players we've seen on the Minecraft server
|
||||
pub known_players: HashSet<MinecraftPlayer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
@ -22,6 +25,16 @@ pub struct GuildMember {
|
|||
pub id: UserId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MinecraftPlayer {
|
||||
/// Unique identifier
|
||||
pub uuid: String,
|
||||
/// Current ingame name
|
||||
pub ign: String,
|
||||
/// Is this account a cam account
|
||||
pub is_cam_acc: bool,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub async fn load() -> Result<FileDatabase<Self, Yaml>> {
|
||||
let db = FileDatabase::load_from_path_or_default(&ARGS.database_path)?;
|
||||
|
@ -68,6 +81,32 @@ impl State {
|
|||
}
|
||||
}
|
||||
|
||||
impl MinecraftPlayer {
|
||||
pub async fn from_uuid(ctx: &Context, uuid: String) -> Result<Self> {
|
||||
// Try fetching the player from our state
|
||||
let player = State::read(ctx, |state| state.known_players.get(&uuid).cloned()).await?;
|
||||
// If player is none, query the rcon for missing information
|
||||
let player = match player {
|
||||
Some(player) => player,
|
||||
None => {
|
||||
let whois = RconHandle::cmd(&WhoIsCmd(&uuid), ctx).await?;
|
||||
let parent_group = RconHandle::cmd(&LuckPermsParentOfCmd(&uuid), ctx).await?;
|
||||
let player = MinecraftPlayer {
|
||||
uuid: whois.uuid,
|
||||
ign: whois.nick,
|
||||
is_cam_acc: parent_group == ARGS.cam_group_name,
|
||||
};
|
||||
State::write(ctx, |state| state.known_players.insert(player.clone()))
|
||||
.await
|
||||
.log_warn("adding player to set of known players");
|
||||
player
|
||||
}
|
||||
};
|
||||
|
||||
Ok(player)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserId> for GuildMember {
|
||||
fn from(id: UserId) -> Self {
|
||||
Self { id }
|
||||
|
@ -86,10 +125,30 @@ impl PartialEq for GuildMember {
|
|||
}
|
||||
}
|
||||
|
||||
impl Eq for GuildMember {}
|
||||
|
||||
impl Borrow<UserId> for GuildMember {
|
||||
fn borrow(&self) -> &UserId {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for GuildMember {}
|
||||
|
||||
impl Hash for MinecraftPlayer {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.uuid.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for MinecraftPlayer {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.uuid.eq(&other.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for MinecraftPlayer {}
|
||||
|
||||
impl Borrow<String> for MinecraftPlayer {
|
||||
fn borrow(&self) -> &String {
|
||||
&self.uuid
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ pub async fn track_server_status(ctx: Context) {
|
|||
loop {
|
||||
tracing::info!("Collecting status information");
|
||||
// Fetch status via rcon and if successful, push it to the influx db
|
||||
if let Some(status) = Status::get_from_rcon(&ctx)
|
||||
if let Some(status) = Status::assemble(&ctx)
|
||||
.await
|
||||
.log_warn("fetching status via rcon")
|
||||
{
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
use influx_db_client::Point;
|
||||
use serenity::client::Context;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
mod player_info;
|
||||
mod tps;
|
||||
|
||||
use crate::{error::Result, influx::InfluxDb, rcon::RconHandle};
|
||||
use crate::{
|
||||
error::Result,
|
||||
influx::InfluxDb,
|
||||
rcon::RconHandle,
|
||||
state::{MinecraftPlayer, State},
|
||||
};
|
||||
|
||||
use self::{
|
||||
player_info::{ListCmd, PlayerInfo},
|
||||
|
@ -13,14 +20,37 @@ use self::{
|
|||
|
||||
pub struct Status {
|
||||
pub tps: Tps,
|
||||
pub player_info: PlayerInfo,
|
||||
pub amount_online: u32,
|
||||
/// List of players associated with their online status.
|
||||
pub players: HashMap<MinecraftPlayer, bool>,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub async fn get_from_rcon(ctx: &Context) -> Result<Self> {
|
||||
pub async fn assemble(ctx: &Context) -> Result<Self> {
|
||||
let tps = RconHandle::cmd(&TpsCmd, ctx).await?;
|
||||
let player_info = RconHandle::cmd(&ListCmd, ctx).await?;
|
||||
Ok(Status { tps, player_info })
|
||||
let PlayerInfo {
|
||||
amount_online,
|
||||
players: online_players,
|
||||
} = RconHandle::cmd(&ListCmd, ctx).await?;
|
||||
// Fetch all known offline players aswell
|
||||
let mut players: HashMap<_, _> = State::read(ctx, |state| {
|
||||
state
|
||||
.known_players
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|player| (player, false))
|
||||
.collect()
|
||||
})
|
||||
.await?;
|
||||
for (_ign, uuid) in online_players {
|
||||
players.insert(MinecraftPlayer::from_uuid(ctx, uuid).await?, true);
|
||||
}
|
||||
|
||||
Ok(Status {
|
||||
tps,
|
||||
amount_online,
|
||||
players,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn send_to_influx_db(&self, ctx: &Context) -> Result {
|
||||
|
@ -28,12 +58,16 @@ impl Status {
|
|||
let status = Point::new("status")
|
||||
.add_field("tps", self.tps.min1)
|
||||
// Safe, since we started with u32
|
||||
.add_field("player_count", self.player_info.amount_online as i64);
|
||||
.add_field("player_count", self.amount_online as i64);
|
||||
points.push(status);
|
||||
for player in &self.player_info.players {
|
||||
let point = Point::new("activity").add_tag("user_id", player.uuid.clone());
|
||||
points.push(point);
|
||||
let mut activity = Point::new("activity");
|
||||
for (player, is_online) in &self.players {
|
||||
// Map is_online to {0, 1} as usual, to make grafana more happy
|
||||
let is_online = *is_online as i64;
|
||||
let name = format!("{} ({})", player.ign.clone(), player.uuid.clone());
|
||||
activity = activity.add_field(name, is_online);
|
||||
}
|
||||
points.push(activity);
|
||||
|
||||
InfluxDb::write_points(points.into_iter(), ctx).await
|
||||
}
|
||||
|
|
|
@ -14,13 +14,11 @@ use crate::{
|
|||
|
||||
pub struct ListCmd;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PlayerInfo {
|
||||
pub amount_online: u32,
|
||||
pub players: Vec<Player>,
|
||||
}
|
||||
|
||||
pub struct Player {
|
||||
pub uuid: String,
|
||||
/// (IGN, UUID)
|
||||
pub players: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl RconCommand for ListCmd {
|
||||
|
@ -49,7 +47,7 @@ fn parse_list_result(inp: &str) -> IResult<&str, PlayerInfo> {
|
|||
tag(" of a max of "),
|
||||
complete::u32,
|
||||
tag(" players online: "),
|
||||
separated_list0(tag(", "), parse_player),
|
||||
separated_list0(tag(", "), parse_players),
|
||||
)),
|
||||
|(_, num, _, _max, _, players)| PlayerInfo {
|
||||
amount_online: num,
|
||||
|
@ -58,11 +56,14 @@ fn parse_list_result(inp: &str) -> IResult<&str, PlayerInfo> {
|
|||
))(inp)
|
||||
}
|
||||
|
||||
fn parse_player(inp: &str) -> IResult<&str, Player> {
|
||||
/// Parse `"IGN (0000-...-0000)"` into `("IGN", "0000-...-0000")`.
|
||||
fn parse_players(inp: &str) -> IResult<&str, (String, String)> {
|
||||
map(
|
||||
tuple((is_not(" "), delimited(char('('), is_not(")"), char(')')))),
|
||||
|(_name, uuid): (&str, &str)| Player {
|
||||
uuid: uuid.to_owned(),
|
||||
},
|
||||
tuple((
|
||||
is_not(" "),
|
||||
char(' '),
|
||||
delimited(char('('), is_not(")"), char(')')),
|
||||
)),
|
||||
|(name, _, uuid): (&str, _, &str)| (name.to_owned(), uuid.to_owned()),
|
||||
)(inp)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue