diff --git a/src/battlemetrics.rs b/src/battlemetrics.rs new file mode 100644 index 0000000..19c950e --- /dev/null +++ b/src/battlemetrics.rs @@ -0,0 +1,204 @@ +use serde::{Deserialize, Serialize}; + +use crate::commands::{Context, Error}; + +#[derive(Serialize, Deserialize, Debug)] +struct CommandOptions { + raw: String, +} + +#[derive(Serialize, Deserialize, Debug)] +struct CommandAttributes { + command: String, + options: CommandOptions, +} + +#[derive(Serialize, Deserialize, Debug)] +struct RconCommand { + #[serde(rename = "type")] + type_field: String, + attributes: CommandAttributes, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct RconData { + data: RconCommand, +} + +impl RconData { + pub fn new(type_field: &str, command: &str, raw: &str) -> Self { + RconData { + data: RconCommand { + type_field: type_field.to_string(), + attributes: CommandAttributes { + command: command.to_string(), + options: CommandOptions { + raw: raw.to_string(), + }, + }, + }, + } + } +} + +pub async fn mint_blood( + name: Option, + amount: String, + ctx: Context<'_>, + api_client: &reqwest::Client, +) -> Result<(), Error> { + if let Some(name) = name.clone() { + let player_name = name; + let short_name = "blood"; + + let command_name = format!( + r#"inventory.giveto "{}" "{}" {}"#, + player_name, short_name, amount + ); + println!("{:?}: Running Command: {}", player_name, command_name); + + let rcon_data = RconData::new("rconCommand", "raw", &command_name); + + let serialized_data = if let Ok(data) = serde_json::to_string(&rcon_data) { + data + } else { + println!("error serializing data"); + return Ok(()); + }; + + let server_id = ctx.data().server_id.clone(); + let url = format!( + "https://api.battlemetrics.com/servers/{}/command", + server_id + ); + + let bm_token = ctx.data().bm_token.clone(); + + let res = api_client + .post(&url) + .header("Authorization", format!("Bearer {}", bm_token)) + .header("Content-Type", "application/json") + .body(serialized_data) + .send() + .await?; + + if res.status() == 200 { + let reply = ctx + .channel_id() + .say( + ctx.http(), + format!("{} has been payed {} blood", player_name, amount), + ) + .await; + + if let Err(e) = reply { + println!("error: {}", e); + } + println!("{:?} blood minted.", player_name); + Ok(()) + } else { + println!("{:?} blood failed to mint.", player_name); + let reply = ctx + .channel_id() + .say( + ctx.http(), + format!("Failed to pay {} blood to {}.", amount, player_name), + ) + .await; + + if let Err(e) = reply { + println!("errror: {}", e); + } + + Ok(()) + } + } else { + println!("error minting blood"); + + let reply = ctx + .channel_id() + .say(ctx.http(), "Failed to parse amount") + .await; + if let Err(e) = reply { + println!("errror: {}", e); + } + Ok(()) + } +} + +pub async fn unmute_player( + name: Option, + ctx: Context<'_>, + api_client: &reqwest::Client, +) -> Result<(), Error> { + if let Some(name) = name.clone() { + let player_name = name; + + let command_name = format!(r#"unmute "{}""#, player_name); + println!("{:?}: Running Command: {}", player_name, command_name); + + let rcon_data = RconData::new("rconCommand", "raw", &command_name); + + let serialized_data = if let Ok(data) = serde_json::to_string(&rcon_data) { + data + } else { + println!("error serializing data"); + return Ok(()); + }; + + let server_id = ctx.data().server_id.clone(); + let url = format!( + "https://api.battlemetrics.com/servers/{}/command", + server_id + ); + + let bm_token = ctx.data().bm_token.clone(); + + let res = api_client + .post(&url) + .header("Authorization", format!("Bearer {}", bm_token)) + .header("Content-Type", "application/json") + .body(serialized_data) + .send() + .await?; + + if res.status() == 200 { + let reply = ctx + .channel_id() + .say(ctx.http(), format!("{} has been unmuted", player_name)) + .await; + + if let Err(e) = reply { + println!("error: {}", e); + } + println!("{:?} unmuted.", player_name); + Ok(()) + } else { + println!("Failed to unmute {:?}", player_name); + let reply = ctx + .channel_id() + .say( + ctx.http(), + format!("Failed to unmute player {}.", player_name), + ) + .await; + + if let Err(e) = reply { + println!("errror: {}", e); + } + + Ok(()) + } + } else { + println!("error unmuting player"); + + let reply = ctx + .channel_id() + .say(ctx.http(), "Failed to unmute player") + .await; + if let Err(e) = reply { + println!("errror: {}", e); + } + Ok(()) + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..166da54 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,350 @@ +use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage}; +use qrcode_generator::QrCodeEcc; +use std::{thread::sleep, time::Duration}; +use zebedee_rust::charges::*; + +use crate::{ + battlemetrics::{mint_blood, unmute_player}, + Data, +}; + +pub type Error = Box; +pub type Context<'a> = poise::Context<'a, Data, Error>; + +/// Responds with ln invoice +#[poise::command(prefix_command, track_edits, aliases("amount, name"), slash_command)] +pub async fn giveblood( + ctx: Context<'_>, + #[description = "blood amount to buy"] amount: Option, + #[description = "In game name"] name: Option, +) -> Result<(), Error> { + let zebedee_client = &ctx.data().zbd; + let api_client = &ctx.data().api_client; + + let name_str = name.as_deref().unwrap_or("User"); + + if let Some(amount) = amount { + if let Ok(mut num) = amount.parse::() { + num *= 1000; + let new_amount = num.to_string(); + + let charge = Charge { + amount: new_amount, + description: "Buy Blood".to_string(), + expires_in: 40, + ..Default::default() + }; + + match zebedee_client.create_charge(&charge).await { + Ok(invoice) => { + if let Some(request_data) = invoice.data { + let requested_invoice = + if let Some(requested_invoice) = request_data.invoice { + requested_invoice + } else { + let reply = ctx + .channel_id() + .say(ctx.http(), "Failed to get invoice data.") + .await; + if let Err(e) = reply { + println!("error: {}", e); + } + return Ok(()); + }; + match serde_json::to_string(&requested_invoice.request) { + Ok(serialized_request_data) => { + let data = serialized_request_data.trim_matches('"').to_string(); + let qr_invoice: Vec = qrcode_generator::to_png_to_vec( + data.clone(), + QrCodeEcc::Low, + 1024, + ) + .unwrap(); + + let description = format!("{:?}, please pay {} sats to the following invoice to give {} blood.", name_str, amount, amount); + + let embed = CreateEmbed::new() + .title("Blood Invoice") + .description(description) + .fields(vec![ + ("Amount", amount.clone(), false), + ("Expires in", "40 seconds".to_string(), false), + ("Invoice: ", data.clone(), false), + ]); + + let attachment = + CreateAttachment::bytes(qr_invoice.as_slice(), "qr.png"); + + let builder = + CreateMessage::new().embed(embed).add_file(attachment); + + let invoice_message = + ctx.channel_id().send_message(&ctx.http(), builder).await; + + match invoice_message { + Ok(_) => { + println!("{:?}: invoice sent...", name_str); + } + Err(e) => { + println!("{:?}: Failed to send invoice: {}", name_str, e); + } + }; + } + Err(e) => { + let reply = ctx + .channel_id() + .say( + ctx.http(), + format!("Failed to serialize request data: {}", e), + ) + .await; + + if let Err(e) = reply { + println!("error: {}", e); + } + } + } + + loop { + sleep(Duration::from_millis(1000)); + + match zebedee_client.get_charge(request_data.id.clone()).await { + Ok(charge) => { + if let Some(data) = charge.data { + match data.status.as_str() { + "completed" => { + println!( + "{:?}: payment completed...sending blood...", + name_str + ); + let give_blood = mint_blood( + name.clone(), + amount.clone(), + ctx, + api_client, + ) + .await; + if let Err(e) = give_blood { + println!("sending blood error: {}", e); + } + break; + } + "expired" => { + let reply = ctx + .channel_id() + .say(ctx.http(), "payment expired") + .await; + + if let Err(e) = reply { + println!("error: {}", e); + } + println!("{:?}: payment expired.", name_str); + break; + } + "error" => { + let reply = ctx + .channel_id() + .say(ctx.http(), "payment error") + .await; + + if let Err(e) = reply { + println!("error: {}", e); + } + break; + } + _ => { + println!("{:?}: Waiting for payment...", name_str); + } + } + } + } + Err(_) => { + println!("error..."); + break; + } + } + } + } else { + println!("invoice data error..."); + } + } + Err(e) => { + let reply = ctx + .channel_id() + .say(ctx.http(), format!("Failed to create charge: {}", e)) + .await; + if let Err(e) = reply { + println!("error: {}", e); + } + } + } + } else { + let reply = ctx + .channel_id() + .say(ctx.http(), "Please enter a valid number for the amount.") + .await; + if let Err(e) = reply { + println!("error: {}", e); + } + } + } + + Ok(()) +} + +#[poise::command(prefix_command, track_edits, aliases("name"), slash_command)] +pub async fn unmute( + ctx: Context<'_>, + #[description = "In game name to unmute"] name: Option, +) -> Result<(), Error> { + let zebedee_client = &ctx.data().zbd; + let api_client = &ctx.data().api_client; + + let name_str = name.as_deref().unwrap_or("User"); + + let amount = 1000; + let new_amount = amount * 1000; + + let charge = Charge { + amount: new_amount.to_string(), + description: "unmute player".to_string(), + expires_in: 40, + ..Default::default() + }; + + match zebedee_client.create_charge(&charge).await { + Ok(invoice) => { + if let Some(request_data) = invoice.data { + let requested_invoice = if let Some(requested_invoice) = request_data.invoice { + requested_invoice + } else { + let reply = ctx + .channel_id() + .say(ctx.http(), "Failed to get invoice data.") + .await; + if let Err(e) = reply { + println!("error: {}", e); + } + return Ok(()); + }; + match serde_json::to_string(&requested_invoice.request) { + Ok(serialized_request_data) => { + let data = serialized_request_data.trim_matches('"').to_string(); + let qr_invoice: Vec = + qrcode_generator::to_png_to_vec(data.clone(), QrCodeEcc::Low, 1024) + .unwrap(); + + let description = format!( + "{:?}, please pay {} sats to the following invoice to unmute {}.", + name_str, amount, name_str + ); + + let embed = CreateEmbed::new() + .title("Blood Invoice") + .description(description) + .fields(vec![ + ("Amount", amount.to_string().clone(), false), + ("Expires in", "40 seconds".to_string(), false), + ("Invoice: ", data.clone(), false), + ]); + + let attachment = CreateAttachment::bytes(qr_invoice.as_slice(), "qr.png"); + + let builder = CreateMessage::new().embed(embed).add_file(attachment); + + let invoice_message = + ctx.channel_id().send_message(&ctx.http(), builder).await; + + match invoice_message { + Ok(_) => { + println!("{:?}: invoice sent...", name_str); + } + Err(e) => { + println!("{:?}: Failed to send invoice: {}", name_str, e); + } + }; + } + Err(e) => { + let reply = ctx + .channel_id() + .say( + ctx.http(), + format!("Failed to serialize request data: {}", e), + ) + .await; + + if let Err(e) = reply { + println!("error: {}", e); + } + } + } + + loop { + sleep(Duration::from_millis(1000)); + + match zebedee_client.get_charge(request_data.id.clone()).await { + Ok(charge) => { + if let Some(data) = charge.data { + match data.status.as_str() { + "completed" => { + println!( + "{:?}: payment completed...unmuting player...", + name_str + ); + let unmute_player = + unmute_player(name.clone(), ctx, api_client).await; + if let Err(e) = unmute_player { + println!("unmuting player error: {}", e); + } + break; + } + "expired" => { + let reply = ctx + .channel_id() + .say(ctx.http(), "payment expired") + .await; + + if let Err(e) = reply { + println!("error: {}", e); + } + println!("{:?}: payment expired.", name_str); + break; + } + "error" => { + let reply = + ctx.channel_id().say(ctx.http(), "payment error").await; + + if let Err(e) = reply { + println!("error: {}", e); + } + break; + } + _ => { + println!("{:?}: Waiting for payment...", name_str); + } + } + } + } + Err(_) => { + println!("error..."); + break; + } + } + } + } else { + println!("invoice data error..."); + } + } + Err(e) => { + let reply = ctx + .channel_id() + .say(ctx.http(), format!("Failed to create charge: {}", e)) + .await; + if let Err(e) = reply { + println!("error: {}", e); + } + } + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6ce632f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,63 @@ +use anyhow::Context as _; +use poise::serenity_prelude::{ClientBuilder, GatewayIntents}; +use shuttle_runtime::SecretStore; +use shuttle_serenity::ShuttleSerenity; +use zebedee_rust::ZebedeeClient; + +mod commands; +use commands::*; + +mod battlemetrics; + +pub struct Data { + zbd: ZebedeeClient, + api_client: reqwest::Client, + bm_token: String, + server_id: String, +} + +#[shuttle_runtime::main] +async fn poise(#[shuttle_runtime::Secrets] secret_store: SecretStore) -> ShuttleSerenity { + let zbd_token = secret_store + .get("ZBD_TOKEN") + .context("'ZBD_TOKEN' was not found")?; + + let zebedee_client = ZebedeeClient::new(zbd_token); + + let api_client = reqwest::Client::new(); + + let discord_token = secret_store + .get("DISCORD_TOKEN") + .context("'DISCORD_TOKEN' was not found")?; + let server_id = secret_store + .get("SERVER_ID") + .context("'SERVER_ID' was not found")?; + let bm_token = secret_store + .get("BM_TOKEN") + .context("'BM_TOKEN' was not found")?; + + let framework = poise::Framework::builder() + .options(poise::FrameworkOptions { + commands: vec![giveblood(), unmute()], + ..Default::default() + }) + .setup(|ctx, _ready, framework| { + Box::pin(async move { + poise::builtins::register_globally(ctx, &framework.options().commands).await?; + Ok(Data { + zbd: zebedee_client, + api_client, + bm_token, + server_id, + }) + }) + }) + .build(); + + let client = ClientBuilder::new(discord_token, GatewayIntents::non_privileged()) + .framework(framework) + .await + .map_err(shuttle_runtime::CustomError::new)?; + + Ok(client.into()) +}