diff --git a/src/bin/mock_server.rs b/src/bin/mock_server.rs index 5a64ee5..0c88da0 100644 --- a/src/bin/mock_server.rs +++ b/src/bin/mock_server.rs @@ -1,6 +1,6 @@ use serenity::all::UserId; use UnchainedPlugin::features::discord::DiscordConfig; -use UnchainedPlugin::events::models::{GameChatMessage, Join, Kill, GameEvent, ChatSource, ChatType, CommandActor, CommandSource, CommandExecuted}; +use UnchainedPlugin::events::models::{GameChatMessage, Join, Kill, GameEvent, ChatSource, ChatType, CommandActor, CommandSource, CommandExecuted, Damage, DamageSource}; use UnchainedPlugin::features::events::EVENT_SYSTEM; use UnchainedPlugin::sinfo; use UnchainedPlugin::tools::logger::init_syslog; @@ -13,8 +13,10 @@ fn main() { let config = DiscordConfig { bot_token: "YOUR_TOKEN_HERE".to_string(), admin_role_id: None, - general_channel_id: serenity::all::ChannelId::new(1), - admin_channel_id: None, + dashboard_channel_id: Some(serenity::all::ChannelId::new(1)), + general_chat_channel_id: Some(serenity::all::ChannelId::new(1)), + admin_notification_channel_id: Some(serenity::all::ChannelId::new(1)), + event_log_channel_id: Some(serenity::all::ChannelId::new(1)), mention_on_admin: true, }; @@ -40,7 +42,15 @@ fn main() { EVENT_SYSTEM.game_event_publisher.publish(GameEvent::KillEvent(Kill { killer: k.to_string(), victim: v.to_string(), - weapon: "MockSword".to_string() + source: Damage { + attacker: k.to_string(), + victim: v.to_string(), + damage: DamageSource { + amount: 50.0, + source: "MockSword".to_string(), + attack_type: "Slash".to_string(), + }, + } })); } ["chat", ..] => { diff --git a/src/events/broadcast_message/discord.rs b/src/events/broadcast_message/discord.rs index 5b5907e..0665dd0 100644 --- a/src/events/broadcast_message/discord.rs +++ b/src/events/broadcast_message/discord.rs @@ -1,28 +1,20 @@ use std::sync::Arc; use async_trait::async_trait; -use serenity::all::{ChannelId, Http, RoleId}; +use serenity::all::{Http, RoleId}; use serenity::builder::{CreateAllowedMentions, CreateMessage}; use crate::events::broadcast::{BroadcastMessage, Notify}; use crate::events::bus::Subscriber; +use crate::features::discord::{send_to_channel, SharedDiscordConfig}; pub struct DiscordBroadcastSubscriber { - channel_id: ChannelId, - _admin_channel_id: Option, - admin_role_id: Option, + discord_config: SharedDiscordConfig, discord_http: Arc, } impl DiscordBroadcastSubscriber { - pub fn new( - channel_id: ChannelId, - admin_channel_id: Option, - admin_role_id: Option, - discord_http: Arc, - ) -> Self { + pub fn new(discord_http: Arc, discord_config: SharedDiscordConfig) -> Self { Self { - channel_id, - _admin_channel_id: admin_channel_id, - admin_role_id, + discord_config, discord_http, } } @@ -35,17 +27,17 @@ impl Subscriber for DiscordBroadcastSubscriber { } async fn on_event(&mut self, event: &BroadcastMessage) { + let config = self.discord_config.read().await.clone(); + let admin_role_id = config.admin_role_id.map(RoleId::new); + let mut discord_message: CreateMessage = event.clone().into(); - let http = Arc::clone(&self.discord_http); - let channel_id = self.channel_id; if !event.notify_roles().is_empty() { let mut content = event.content_ref().map(str::to_owned).unwrap_or_default(); for notify_role in event.notify_roles().iter() { let notify_role_id = match notify_role { - Notify::Admin => self.admin_role_id, - // Add others as we create them + Notify::Admin => admin_role_id, }; if let Some(notify_role_id) = notify_role_id { @@ -57,18 +49,9 @@ impl Subscriber for DiscordBroadcastSubscriber { discord_message = discord_message.content(content); } - let allowed_mention = - CreateAllowedMentions::new() - .roles(self.admin_role_id.into_iter()); - - + let allowed_mention = CreateAllowedMentions::new().roles(admin_role_id.into_iter()); + discord_message = discord_message.allowed_mentions(allowed_mention); - tokio::spawn(async move { - let _ = channel_id.send_message( - &http, - discord_message - .allowed_mentions(allowed_mention) - ).await; - }); + send_to_channel(&self.discord_http, config.general_chat_channel_id, discord_message).await; } } diff --git a/src/events/models.rs b/src/events/models.rs index eaa93f6..3d7df7d 100644 --- a/src/events/models.rs +++ b/src/events/models.rs @@ -26,13 +26,21 @@ pub struct Crash { pub struct Kill { pub killer: String, pub victim: String, - pub weapon: String, + pub source: Damage, +} + +#[derive(Debug, Clone)] +pub struct DamageSource { + pub amount: f32, + pub source: String, // weapon or throwable name + pub attack_type: String // slash/overhead/throw/stab/etc; } /// Triggered when the server changes maps #[derive(Debug, Clone)] pub struct MapChange { - pub new_map: String + pub new_map_url: String, + pub new_map_name: String, } /// Triggered when a match finishes (before the map change) @@ -148,6 +156,16 @@ pub struct CommandExecuted { pub source: CommandSource, } +#[derive(Debug, Clone)] +pub struct CommandRejected { + pub name: String, + pub args: Vec, + pub raw_args: String, + pub actor: CommandActor, + pub source: CommandSource, + pub rejection_reason: String, +} + #[derive(Clone, Debug)] pub struct ServerStatus { pub name: String, @@ -172,7 +190,7 @@ pub struct DuelStart { pub challenger: String, pub opponent: String } #[derive(Debug, Clone)] pub struct Attack { pub attacker: String, pub attack_type: String, pub was_parried: bool } #[derive(Debug, Clone)] -pub struct Damage { pub attacker: String, pub victim: String, pub damage: f32 } +pub struct Damage { pub attacker: String, pub victim: String, pub damage: DamageSource } // --- The Unified GameEvent Enum --- @@ -187,6 +205,7 @@ pub enum GameEvent { GameChatMessageEvent(GameChatMessage), CommandRequestEvent(CommandRequest), CommandExecutedEvent(CommandExecuted), + CommandRejectedEvent(CommandRejected), ServerStatusEvent(ServerStatus), AdminAlertEvent(AdminAlert), DuelStartEvent(DuelStart), // Never dispatched diff --git a/src/features/discord.rs b/src/features/discord.rs index 1209857..eb9fa53 100644 --- a/src/features/discord.rs +++ b/src/features/discord.rs @@ -1,13 +1,15 @@ use std::sync::Arc; -use serenity::all::{ChannelId, Context, GatewayIntents, Http, Message, RoleId}; +use serenity::all::{ChannelId, Context, CreateMessage, GatewayIntents, Http, Message, RoleId}; use serenity::Client; use serenity::client::EventHandler as DiscordHandler; +use tokio::sync::RwLock; use crate::events::bus::EventPublisher; use crate::events::models::{ActorIdentity, ActorPermissions, ChatSource, ChatType, CommandActor, CommandSource, GameChatMessage, CommandRequest, GameEvent, PermissionFlags}; use crate::events::broadcast_message::discord::DiscordBroadcastSubscriber; use crate::modules::chat::DiscordChatSink; use crate::modules::discord::admin_alert::AdminAlertModule; use crate::modules::discord::dashboard::DashboardSubscriber; +use crate::modules::discord::event_log::EventLogSubscriber; use crate::features::events::EVENT_SYSTEM; use crate::features::tokio_runtime::TOKIO_RUNTIME; use crate::{sinfo, swarn}; @@ -16,25 +18,53 @@ use crate::{sinfo, swarn}; pub struct DiscordConfig { pub bot_token: String, pub admin_role_id: Option, - pub general_channel_id: ChannelId, - pub admin_channel_id: Option, + pub dashboard_channel_id: Option, + pub general_chat_channel_id: Option, + pub admin_notification_channel_id: Option, + pub event_log_channel_id: Option, pub mention_on_admin: bool, } +pub type SharedDiscordConfig = Arc>; + +pub async fn send_to_channel( + http: &Http, + channel_id: Option, + message: CreateMessage, +) { + match channel_id { + Some(id) => { + if let Err(e) = id.send_message(http, message).await { + swarn!(f; "Failed to send to channel {}: {}", id, e); + } + } + None => { + swarn!(f; "Attempted to send to a channel that is not configured."); + } + } +} + pub struct Discord { - config: DiscordConfig, + config: SharedDiscordConfig, event_publisher: EventPublisher, } -pub fn initialize_discord_system(config: DiscordConfig) { +pub fn initialize_discord_system(config: DiscordConfig) -> SharedDiscordConfig { let event_publisher = EVENT_SYSTEM.game_event_publisher.clone(); + let shared_config = Arc::new(RwLock::new(config)); + let task_config = Arc::clone(&shared_config); TOKIO_RUNTIME.spawn(async move { let intents = GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT; - - let mut client = match Client::builder(&config.bot_token, intents) + + let bot_token = { + let config = task_config.read().await; + config.bot_token.clone() + }; + + let mut client = match Client::builder(&bot_token, intents) .event_handler(Discord { - config: config.clone(), + config: Arc::clone(&task_config), event_publisher }) .await { @@ -45,61 +75,59 @@ pub fn initialize_discord_system(config: DiscordConfig) { } }; - register_discord_subscribers(config.clone(), client.http.clone()).await; - add_discord_chat_sink(config.clone(), client.http.clone()); + register_discord_subscribers(Arc::clone(&task_config), client.http.clone()).await; + add_discord_chat_sink(Arc::clone(&task_config), client.http.clone()); if let Err(e) = client.start().await { swarn!(f; "Discord bot error: {}", e); } }); + + shared_config } -async fn register_discord_subscribers(config: DiscordConfig, http: Arc) { +async fn register_discord_subscribers(config: SharedDiscordConfig, http: Arc) { let broadcast_message_bus = &EVENT_SYSTEM.message_broadcast_event_bus; - let discord_subscriber = DiscordBroadcastSubscriber::new( - config.general_channel_id, - config.admin_channel_id, - config.admin_role_id.map(serenity::all::RoleId::new), - http.clone(), - ); + let discord_subscriber = DiscordBroadcastSubscriber::new(http.clone(), Arc::clone(&config)); let _ = broadcast_message_bus.subscribe(Box::new(discord_subscriber)).await; // Admin Alert Module - if let Some(admin_channel_id) = config.admin_channel_id { - let admin_alert_module = AdminAlertModule::new( - config.mention_on_admin, - http.clone(), - admin_channel_id, - config.admin_role_id.map(RoleId::new), - &EVENT_SYSTEM.game_event_publisher, - ); - let _ = EVENT_SYSTEM.game_event_bus.subscribe(Box::new(admin_alert_module.clone())).await; - - let command_subscriber = EVENT_SYSTEM.command_subscriber.clone(); - TOKIO_RUNTIME.spawn(async move { - let mut command_subscriber = command_subscriber.lock().await; - command_subscriber.register(admin_alert_module); - }); - } + let admin_alert_module = AdminAlertModule::new( + http.clone(), + Arc::clone(&config), + &EVENT_SYSTEM.game_event_publisher, + ); + let _ = EVENT_SYSTEM.game_event_bus.subscribe(Box::new(admin_alert_module.clone())).await; + + let command_subscriber = EVENT_SYSTEM.command_subscriber.clone(); + TOKIO_RUNTIME.spawn(async move { + let mut command_subscriber = command_subscriber.lock().await; + command_subscriber.register(admin_alert_module); + }); // Dashboard Subscriber - // Assuming we use the general channel for the dashboard, or we could add a dedicated field to DiscordConfig - let dashboard_subscriber = DashboardSubscriber::new(http.clone(), config.general_channel_id); + let dashboard_subscriber = DashboardSubscriber::new(http.clone(), Arc::clone(&config)); let _ = EVENT_SYSTEM.game_event_bus.subscribe(Box::new(dashboard_subscriber)).await; + + // Event Log Subscriber + let should_register_event_log = { + let cfg = config.read().await; + cfg.event_log_channel_id.is_some() + }; + if should_register_event_log { + let event_log_subscriber = EventLogSubscriber::new(http.clone(), Arc::clone(&config)); + let _ = EVENT_SYSTEM.game_event_bus.subscribe(Box::new(event_log_subscriber)).await; + } else { + swarn!(f; "Discord event log subscriber not registered because event log channel is not configured."); + } } -fn add_discord_chat_sink(discord_config: DiscordConfig, discord_http: Arc) { +fn add_discord_chat_sink(discord_config: SharedDiscordConfig, discord_http: Arc) { TOKIO_RUNTIME.spawn(async move { - if let Some(admin_id) = discord_config.admin_channel_id { - EVENT_SYSTEM.chat_relay_subscriber.add_sink(Box::new(DiscordChatSink::new( - discord_config.general_channel_id, - admin_id, - discord_http, - ))).await; - } else { - swarn!(f; "Discord chat sink not added because admin channel id is missing."); - } + EVENT_SYSTEM.chat_relay_subscriber + .add_sink(Box::new(DiscordChatSink::new(discord_config, discord_http))) + .await; }); } @@ -114,12 +142,18 @@ impl DiscordHandler for Discord { return; } - if Some(msg.channel_id) != Some(self.config.general_channel_id) && Some(msg.channel_id) != self.config.admin_channel_id { + let config = self.config.read().await.clone(); + + let is_known_channel = + config.general_chat_channel_id == Some(msg.channel_id) + || config.admin_notification_channel_id == Some(msg.channel_id); + + if !is_known_channel { return; } let roles = msg.member.as_ref().map(|m| m.roles.clone()).unwrap_or_default(); - let is_admin = self.config.admin_role_id.map(|id| roles.contains(&RoleId::new(id))).unwrap_or(false); + let is_admin = config.admin_role_id.map(|id| roles.contains(&RoleId::new(id))).unwrap_or(false); let actor = CommandActor { display_name: msg.author.name.clone(), @@ -154,7 +188,7 @@ impl DiscordHandler for Discord { actor, source: CommandSource::Discord, })); - } else if Some(msg.channel_id) == Some(self.config.general_channel_id) { + } else if config.general_chat_channel_id == Some(msg.channel_id) { self.event_publisher.publish(GameEvent::GameChatMessageEvent(GameChatMessage { chat_source: ChatSource::Discord, chat_type: ChatType::Global, diff --git a/src/features/events.rs b/src/features/events.rs index f14c5ac..6d2ae35 100644 --- a/src/features/events.rs +++ b/src/features/events.rs @@ -20,7 +20,6 @@ use crate::modules::stats::{StatsTrackerSubscriber, StatsTrackerState, TopComman use crate::modules::duel::DuelManagerSubscriber; use crate::modules::say::SayCommand; use crate::modules::cmd::CmdCommand; -use crate::modules::crash::CrashSubscriber; // use crate::modules::discord::admin_alert::AdminAlertModule; // use crate::modules::discord::dashboard::DashboardSubscriber; use crate::modules::votes::votespeed::SpeedVote; @@ -42,7 +41,6 @@ pub struct UnchainedEventSystem { pub chat_relay_subscriber: ChatRelaySubscriber, pub command_subscriber: Arc>, - pub mention_on_crash: bool, } pub struct CommandSubscriberProxy { @@ -96,7 +94,6 @@ fn initialize_event_buses() -> UnchainedEventSystem { message_broadcast_event_publisher: message_broadcast_event_publisher.clone(), chat_relay_subscriber, command_subscriber, - mention_on_crash: true, } } @@ -135,7 +132,6 @@ pub async fn initialize_subscribers() { let _ = game_event_bus.subscribe(Box::new(StatsTrackerSubscriber::new(stats_state, broadcast_message_publisher))).await; let _ = game_event_bus.subscribe(Box::new(DuelManagerSubscriber::new(broadcast_message_publisher))).await; - let _ = game_event_bus.subscribe(Box::new(CrashSubscriber::new(EVENT_SYSTEM.mention_on_crash, broadcast_message_publisher))).await; } async fn initalize_vote_commands(command_subscriber: &mut CommandSubscriber, broadcast_event_publisher: &'static EventPublisher) { diff --git a/src/lib.rs b/src/lib.rs index 88906b9..4461453 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -495,16 +495,15 @@ pub fn world_init() { message: "Initialized Event System".to_string(), })); - if let (Some(bot_token), Some(general_channel_id)) = ( - cli_args().discord_bot_token.clone(), - cli_args().discord_general_channel_id.map(ChannelId::new) - ) { - features::discord::initialize_discord_system(DiscordConfig { + if let Some(bot_token) = cli_args().discord_bot_token.clone() { + let _discord_config = features::discord::initialize_discord_system(DiscordConfig { bot_token, - general_channel_id, - admin_channel_id: cli_args().discord_admin_channel_id.map(ChannelId::new), + dashboard_channel_id: cli_args().discord_dashboard_channel_id.map(ChannelId::new), + general_chat_channel_id: cli_args().discord_general_channel_id.map(ChannelId::new), + admin_notification_channel_id: cli_args().discord_admin_channel_id.map(ChannelId::new), + event_log_channel_id: cli_args().discord_event_log_channel_id.map(ChannelId::new), admin_role_id: cli_args().discord_admin_role_id, - mention_on_admin: cli_args().discord_mention_on_admin, + mention_on_admin: cli_args().discord_mention_admins, }); EVENT_SYSTEM.game_event_publisher.publish(GameChatMessageEvent(GameChatMessage { diff --git a/src/modules/chat.rs b/src/modules/chat.rs index 29e4559..d13b46d 100644 --- a/src/modules/chat.rs +++ b/src/modules/chat.rs @@ -2,11 +2,12 @@ use std::sync::Arc; use tokio::sync::Mutex; use async_trait::async_trait; use futures::future::join_all; -use serenity::all::{ChannelId, CreateMessage}; +use serenity::all::CreateMessage; use serenity::http::Http; use crate::sinfo; use crate::events::bus::Subscriber; use crate::events::models::{ChatSource, ChatType, GameChatMessage, GameEvent}; +use crate::features::discord::{send_to_channel, SharedDiscordConfig}; use crate::game; use crate::game::chivalry2::EChatType; @@ -43,14 +44,13 @@ impl ChatSink for GameChatSink { } pub struct DiscordChatSink { - general_chat_channel_id: ChannelId, - admin_chat_channel_id: ChannelId, + discord_config: SharedDiscordConfig, discord_http: Arc } impl DiscordChatSink { - pub fn new(general_chat_channel_id: ChannelId, admin_chat_channel_id: ChannelId, discord_http: Arc) -> Self { - Self { general_chat_channel_id, admin_chat_channel_id, discord_http } + pub fn new(discord_config: SharedDiscordConfig, discord_http: Arc) -> Self { + Self { discord_config, discord_http } } } @@ -58,20 +58,21 @@ impl DiscordChatSink { impl ChatSink for DiscordChatSink { async fn send(&self, chat_source: &ChatSource, chat_type: &ChatType, sender: &String, text: &String) { if chat_source == &ChatSource::Discord { return }; + let config = self.discord_config.read().await.clone(); - let maybe_channel_id = match chat_type { - ChatType::Admin => Some(self.admin_chat_channel_id), - ChatType::Global => Some(self.general_chat_channel_id), - ChatType::Team => None, // Don't want to send team messages to discord in case they're actually private/strategic + let chat_type_string = match chat_type { + ChatType::Admin => "[Admin] ", + ChatType::Global => "", + ChatType::Team => return, }; - if let Some(channel_id) = maybe_channel_id { - let message = format!("**{}**: {}", sender, text); - let _ = channel_id - .send_message(&self.discord_http, CreateMessage::new().content(message)) - .await - .inspect_err(|e| eprintln!("Failed to send Discord message: {:?}", e)); - }; + let message = format!("**{}{}**: {}", chat_type_string, sender, text); + send_to_channel( + &self.discord_http, + config.general_chat_channel_id, + CreateMessage::new().content(message), + ) + .await; } } diff --git a/src/modules/command.rs b/src/modules/command.rs index abc2715..210b37c 100644 --- a/src/modules/command.rs +++ b/src/modules/command.rs @@ -26,7 +26,7 @@ use async_trait::async_trait; use clap::{Parser, CommandFactory}; use crate::events::bus::{Subscriber, EventPublisher}; -use crate::events::models::{GameEvent, CommandExecuted, CommandRequest}; +use crate::events::models::{CommandExecuted, CommandRejected, CommandRequest, GameEvent}; use crate::events::broadcast::BroadcastMessage; // use crate::features::events::EVENT_SYSTEM; @@ -40,6 +40,11 @@ pub struct CommandSubscriber { game_event_publisher: &'static EventPublisher, } +enum CommandOutcome { + Executed, + Rejected(CommandRejected), +} + impl CommandSubscriber { pub fn new( broadcaster: &'static EventPublisher, @@ -85,25 +90,48 @@ impl CommandSubscriber { } } - async fn handle_command(&self, command: &Box, req: &CommandRequest) { + async fn handle_command(&self, command: &dyn ErasedCommand, req: &CommandRequest) -> CommandOutcome { if let Some(required_source) = command.required_source() { if required_source != req.source { + let rejection_reason = format!( + "Command '{}' must be executed from {}, but request source was {}", + req.name, required_source, req.source + ); self .broadcaster .publish(format!("Error: User '{}' attempted to execute command '{}' from {}, but it must be executed from {}", req.actor.display_name, req.name, req.source, required_source).into()); - return; + return CommandOutcome::Rejected(CommandRejected { + name: req.name.clone(), + args: req.args.clone(), + raw_args: req.raw_args.clone(), + actor: req.actor.clone(), + source: req.source.clone(), + rejection_reason, + }); } } if !req.actor.permissions.flags.contains(command.required_permissions()) { + let rejection_reason = format!( + "User '{}' is missing required permissions to execute '{}'", + req.actor.display_name, req.name + ); self .broadcaster .publish(format!("Error: User '{}' is not allowed to execute command '{}'.", req.actor.display_name, req.name).into()); - return; + return CommandOutcome::Rejected(CommandRejected { + name: req.name.clone(), + args: req.args.clone(), + raw_args: req.raw_args.clone(), + actor: req.actor.clone(), + source: req.source.clone(), + rejection_reason, + }); } sinfo!("User '{}' executed command '{} {}'", req.actor.display_name, req.name, req.raw_args); command.execute(req).await; + CommandOutcome::Executed } } @@ -128,20 +156,35 @@ impl Subscriber for CommandSubscriber { self.commands.iter().find(|c| c.name() == req.name); if maybe_command.is_none() { + let rejection_reason = format!("Command '{}' does not exist", req.name); self.broadcaster.publish(format!("User '{}' attempted to execute non-existent command '{}'.", req.actor.display_name, req.name).into()); + let rejected = CommandRejected { + name: req.name.clone(), + args: req.args.clone(), + raw_args: req.raw_args.clone(), + actor: req.actor.clone(), + source: req.source.clone(), + rejection_reason, + }; + self.game_event_publisher.publish(GameEvent::CommandRejectedEvent(rejected)); return; } - self.handle_command(maybe_command.unwrap(), req).await; - - let cmd = CommandExecuted { - name: req.name.clone(), - args: req.args.clone(), - raw_args: req.raw_args.clone(), - actor: req.actor.clone(), - source: req.source.clone(), - }; - self.game_event_publisher.publish(GameEvent::CommandExecutedEvent(cmd)); + match self.handle_command(maybe_command.unwrap().as_ref(), req).await { + CommandOutcome::Executed => { + let cmd = CommandExecuted { + name: req.name.clone(), + args: req.args.clone(), + raw_args: req.raw_args.clone(), + actor: req.actor.clone(), + source: req.source.clone(), + }; + self.game_event_publisher.publish(GameEvent::CommandExecutedEvent(cmd)); + } + CommandOutcome::Rejected(rejected) => { + self.game_event_publisher.publish(GameEvent::CommandRejectedEvent(rejected)); + } + } } } diff --git a/src/modules/crash.rs b/src/modules/crash.rs deleted file mode 100644 index e5cd97b..0000000 --- a/src/modules/crash.rs +++ /dev/null @@ -1,37 +0,0 @@ -use async_trait::async_trait; -use crate::events::broadcast::BroadcastMessage; -use crate::events::bus::{EventPublisher, Subscriber}; -use crate::events::models::GameEvent; - -pub struct CrashSubscriber { - mention_on_crash: bool, - broadcaster: &'static EventPublisher, -} - -impl CrashSubscriber { - pub fn new(mention_on_crash: bool, broadcaster: &'static EventPublisher) -> Self { - Self { mention_on_crash, broadcaster } - } -} - -#[async_trait] -impl Subscriber for CrashSubscriber { - fn identifier(&self) -> &'static str { - "CrashSubscriber" - } - - async fn on_event(&mut self, event: &GameEvent) { - if let GameEvent::CrashEvent(alert) = event { - let msg = BroadcastMessage::new() - .title("🚨 SERVER CRASH".to_string()) - .content(format!("**{}** \ntrace: \n```\n{}\n```", alert.event_type, alert.event_trace.join("\n"))); - - if self.mention_on_crash { - // msg = msg.notify(Notify::Admin); - } - self.broadcaster.publish(msg); - } - } - - async fn on_tick(&mut self) {} -} diff --git a/src/modules/discord/admin_alert.rs b/src/modules/discord/admin_alert.rs index 2bc50e5..5fe5ef2 100644 --- a/src/modules/discord/admin_alert.rs +++ b/src/modules/discord/admin_alert.rs @@ -1,36 +1,30 @@ -use std::sync::Arc; +use std::sync::Arc; use async_trait::async_trait; use clap::Parser; -use serenity::all::{ChannelId, CreateAllowedMentions, CreateMessage, Http, RoleId}; +use serenity::all::{CreateAllowedMentions, CreateMessage, Http, RoleId}; use serenity::builder::CreateEmbed; -// use crate::events::broadcast::{BroadcastMessage, Notify}; use crate::events::bus::{EventPublisher, Subscriber}; +use crate::features::discord::{send_to_channel, SharedDiscordConfig}; use crate::commands::Command; use crate::events::models::{AdminAlert, CommandRequest, GameEvent, PermissionFlags}; use crate::swarn; pub struct AdminAlertModule { - mention_on_admin: bool, http: Arc, - channel_id: ChannelId, - admin_role_id: Option, + discord_config: SharedDiscordConfig, game_event_publisher: &'static EventPublisher, } impl AdminAlertModule { pub fn new( - mention_on_admin: bool, http: Arc, - channel_id: ChannelId, - admin_role_id: Option, + discord_config: SharedDiscordConfig, game_event_publisher: &'static EventPublisher, ) -> Self { Self { - mention_on_admin, http, - channel_id, - admin_role_id, + discord_config, game_event_publisher, } } @@ -39,10 +33,8 @@ impl AdminAlertModule { impl Clone for AdminAlertModule { fn clone(&self) -> Self { Self { - mention_on_admin: self.mention_on_admin, http: self.http.clone(), - channel_id: self.channel_id, - admin_role_id: self.admin_role_id, + discord_config: self.discord_config.clone(), game_event_publisher: self.game_event_publisher, } } @@ -56,17 +48,18 @@ impl Subscriber for AdminAlertModule { async fn on_event(&mut self, event: &GameEvent) { if let GameEvent::AdminAlertEvent(alert) = event { + let config = self.discord_config.read().await.clone(); let mut msg = CreateMessage::new().embed( CreateEmbed::new() .title("🚨 Admin Alert".to_string()) .description(format!("`{}` reports: *\"{}\"*", alert.reporter, alert.reason)), ); - if self.mention_on_admin { - if let Some(role_id) = &self.admin_role_id { + if config.mention_on_admin { + if let Some(role_id) = config.admin_role_id.map(RoleId::new) { msg = msg .allowed_mentions( - CreateAllowedMentions::default().roles(vec![role_id.clone()]), + CreateAllowedMentions::default().roles(vec![role_id]), ) .content(format!("<@&{}> ", role_id)); } else { @@ -74,7 +67,12 @@ impl Subscriber for AdminAlertModule { } } - let _ = self.channel_id.send_message(&self.http, msg).await; + send_to_channel( + &self.http, + config.admin_notification_channel_id, + msg, + ) + .await; } } } diff --git a/src/modules/discord/dashboard.rs b/src/modules/discord/dashboard.rs index 9f157ce..884f7ab 100644 --- a/src/modules/discord/dashboard.rs +++ b/src/modules/discord/dashboard.rs @@ -4,6 +4,8 @@ use async_trait::async_trait; use serenity::all::{ChannelId, Http, CreateMessage, MessageId, EditMessage}; use crate::events::bus::Subscriber; use crate::events::models::{GameEvent, ServerStatus}; +use crate::features::discord::SharedDiscordConfig; +use crate::swarn; pub struct DashboardSubscriber { // Current State @@ -15,7 +17,8 @@ pub struct DashboardSubscriber { // Discord Reference http: Arc, - channel_id: ChannelId, + discord_config: SharedDiscordConfig, + active_channel_id: Option, message_id: Option, needs_refresh: bool, @@ -23,7 +26,7 @@ pub struct DashboardSubscriber { } impl DashboardSubscriber { - pub fn new(http: Arc, channel_id: ChannelId) -> Self { + pub fn new(http: Arc, discord_config: SharedDiscordConfig) -> Self { Self { player_count: 0, max_players: 0, @@ -31,7 +34,8 @@ impl DashboardSubscriber { server_name: "Unchained Server".to_string(), last_update: Instant::now(), http, - channel_id, + discord_config, + active_channel_id: None, message_id: None, needs_refresh: true, status: None, @@ -92,7 +96,7 @@ impl Subscriber for DashboardSubscriber { self.needs_refresh = true; } GameEvent::MapChangeEvent(e) => { - self.current_map = e.new_map.clone(); + self.current_map = e.new_map_url.clone(); self.needs_refresh = true; } _ => {} @@ -100,23 +104,36 @@ impl Subscriber for DashboardSubscriber { } async fn on_tick(&mut self) { - // Only refresh every 30 seconds or if a major event happened if !self.needs_refresh && self.message_id.is_none() { return; } - + if !self.needs_refresh && self.last_update.elapsed() < Duration::from_secs(30) { return; } + let channel_id = match self.discord_config.read().await.dashboard_channel_id { + Some(id) => id, + None => { + swarn!(f; "Attempted to update dashboard, but dashboard channel is not configured."); + return; + } + }; + + if self.active_channel_id != Some(channel_id) { + self.active_channel_id = Some(channel_id); + self.message_id = None; + self.needs_refresh = true; + } + let embed = self.build_embed(); match self.message_id { Some(id) => { - let _ = self.channel_id.edit_message(&self.http, id, EditMessage::new().add_embed(embed)).await; + let _ = channel_id.edit_message(&self.http, id, EditMessage::new().add_embed(embed)).await; } None => { - if let Ok(msg) = self.channel_id.send_message(&self.http, CreateMessage::new().add_embed(embed)).await { + if let Ok(msg) = channel_id.send_message(&self.http, CreateMessage::new().add_embed(embed)).await { self.message_id = Some(msg.id); } } diff --git a/src/modules/discord/event_log.rs b/src/modules/discord/event_log.rs new file mode 100644 index 0000000..6357bf9 --- /dev/null +++ b/src/modules/discord/event_log.rs @@ -0,0 +1,202 @@ +use std::sync::Arc; +use async_trait::async_trait; +use serenity::all::{CreateEmbed, CreateEmbedFooter, CreateMessage, Http}; +use crate::events::bus::Subscriber; +use crate::events::models::GameEvent; +use crate::features::discord::{send_to_channel, SharedDiscordConfig}; + +pub struct EventLogSubscriber { + http: Arc, + discord_config: SharedDiscordConfig, +} + +impl EventLogSubscriber { + pub fn new(http: Arc, discord_config: SharedDiscordConfig) -> Self { + Self { + http, + discord_config, + } + } + + fn build_embed(event: &GameEvent) -> CreateEmbed { + let (emoji, title, description, color) = match event { + GameEvent::JoinEvent(join) => ( + "📥", + "Player Joined", + format!("**{}** joined the server.", join.name), + 0x57F287, + ), + GameEvent::LeaveEvent(leave) => ( + "📤", + "Player Left", + format!("**{}** left the server.", leave.name), + 0xED4245, + ), + GameEvent::CrashEvent(crash) => ( + "💀", + "SERVER CRASH", + format!( + "**{}** \ntrace: \n```\n{}\n```", + crash.event_type, + crash.event_trace.join("\n") + ), + 0x992D22, + ), + GameEvent::KillEvent(kill) => ( + "⚔️", + "Kill Event", + format!( + "**Killer:** {}\n**Victim:** {}\n**Weapon:** {}\n**Attack Type:** {}\n**Damage:** {:.2}", + kill.killer, + kill.victim, + kill.source.damage.source, + kill.source.damage.attack_type, + kill.source.damage.amount + ), + 0xC53030, + ), + GameEvent::MapChangeEvent(map_change) => ( + "🗺️", + "Map Change", + format!("Map changed to **{}**.", map_change.new_map_url), + 0x5865F2, + ), + GameEvent::MatchEndEvent(match_end) => ( + "🏁", + "Match End", + format!( + "**Winner:** {}\n**Final Score:** {}", + match_end.winner_team, match_end.final_score + ), + 0xFEE75C, + ), + GameEvent::GameChatMessageEvent(chat) => ( + "💬", + "Game Chat Message", + format!( + "**Source:** {:?}\n**Type:** {:?}\n**Sender:** {}\n**Message:** {}", + chat.chat_source, chat.chat_type, chat.sender, chat.message + ), + 0x1ABC9C, + ), + GameEvent::CommandRequestEvent(command) => ( + "📝", + "Command Requested", + format!( + "**Name:** {}\n**Source:** {:?}\n**Actor:** {}\n**Args:** {}\n**Raw:** `{}`", + command.name, + command.source, + command.actor.display_name, + if command.args.is_empty() { + "".to_string() + } else { + command.args.join(" ") + }, + command.raw_args + ), + 0x3498DB, + ), + GameEvent::CommandExecutedEvent(command) => ( + "✅", + "Command Executed", + format!( + "**Name:** {}\n**Source:** {:?}\n**Actor:** {}\n**Args:** {}\n**Raw:** `{}`", + command.name, + command.source, + command.actor.display_name, + if command.args.is_empty() { + "".to_string() + } else { + command.args.join(" ") + }, + command.raw_args + ), + 0x2ECC71, + ), + GameEvent::CommandRejectedEvent(command) => ( + "⛔", + "Command Rejected", + format!( + "**Name:** {}\n**Source:** {:?}\n**Actor:** {}\n**Args:** {}\n**Raw:** `{}`\n**Reason:** {}", + command.name, + command.source, + command.actor.display_name, + if command.args.is_empty() { + "".to_string() + } else { + command.args.join(" ") + }, + command.raw_args, + command.rejection_reason + ), + 0xE74C3C, + ), + GameEvent::ServerStatusEvent(status) => ( + "📊", + "Server Status", + format!( + "**Name:** {}\n**Map:** {}\n**Players:** {}/{}\n**Password Protected:** {}", + status.name, + status.current_map, + status.player_count, + status.max_players, + status.password_protected + ), + 0x5DADE2, + ), + GameEvent::AdminAlertEvent(alert) => ( + "🚨", + "Admin Alert", + format!("**Reporter:** {}\n**Reason:** {}", alert.reporter, alert.reason), + 0xFF6B6B, + ), + GameEvent::DuelStartEvent(duel) => ( + "🤺", + "Duel Start", + format!("**Challenger:** {}\n**Opponent:** {}", duel.challenger, duel.opponent), + 0x9B59B6, + ), + GameEvent::AttackEvent(attack) => ( + "🗡️", + "Attack Event", + format!( + "**Attacker:** {}\n**Type:** {}\n**Parried:** {}", + attack.attacker, attack.attack_type, attack.was_parried + ), + 0xE67E22, + ), + GameEvent::DamageEvent(damage) => ( + "🩸", + "Damage Event", + format!( + "**Attacker:** {}\n**Victim:** {}\n**Damage:** {:.2}\n**Source:** {}\n**Attack Type:** {}", + damage.attacker, + damage.victim, + damage.damage.amount, + damage.damage.source, + damage.damage.attack_type + ), + 0xE74C3C, + ), + }; + + CreateEmbed::new() + .title(format!("{} {}", emoji, title)) + .description(description) + .color(color) + .footer(CreateEmbedFooter::new("Unchained Event Log")) + } +} + +#[async_trait] +impl Subscriber for EventLogSubscriber { + fn identifier(&self) -> &'static str { + "EventLogSubscriber" + } + + async fn on_event(&mut self, event: &GameEvent) { + let event_log_channel_id = self.discord_config.read().await.event_log_channel_id; + let message = CreateMessage::new().embed(Self::build_embed(event)); + send_to_channel(&self.http, event_log_channel_id, message).await; + } +} diff --git a/src/modules/discord/mod.rs b/src/modules/discord/mod.rs index 52e5e40..0d29b5c 100644 --- a/src/modules/discord/mod.rs +++ b/src/modules/discord/mod.rs @@ -1,2 +1,3 @@ -pub mod admin_alert; +pub mod admin_alert; pub mod dashboard; +pub mod event_log; diff --git a/src/modules/duel.rs b/src/modules/duel.rs index 93cba2a..b4dad80 100644 --- a/src/modules/duel.rs +++ b/src/modules/duel.rs @@ -76,7 +76,7 @@ impl DuelManagerSubscriber { let is_p2 = e.victim == duel.p2; if is_p1 || is_p2 { - *duel.damage_dealt.entry(e.attacker.clone()).or_insert(0.0) += e.damage; + *duel.damage_dealt.entry(e.attacker.clone()).or_insert(0.0) += e.damage.amount; } else if e.attacker == duel.p1 || e.attacker == duel.p2 { self.state = DuelState::Idle; self.broadcaster.publish(BroadcastMessage::from("?? **Duel Cancelled**: Interference detected!")); diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 6c41768..eb3a4b4 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,4 +1,4 @@ -pub mod chat; +pub mod chat; pub mod command; pub mod killstreak; pub mod join_batcher; @@ -9,7 +9,6 @@ pub mod cmd; pub mod mod_dump; pub mod game_info; pub mod mod_list; -pub mod crash; pub mod vote; pub mod votes; pub mod discord; \ No newline at end of file diff --git a/src/resolvers/unchained_integration.rs b/src/resolvers/unchained_integration.rs index 9e01aea..f0cfa57 100644 --- a/src/resolvers/unchained_integration.rs +++ b/src/resolvers/unchained_integration.rs @@ -132,11 +132,9 @@ CREATE_HOOK!(UGameEngineTick, ACTIVE, NONE, (), (engine:*mut c_void, delta:f32, define_pattern_resolver!(OnPreLoadMap,["48 89 74 24 10 57 48 83 EC 50 83 B9 40 08 00 00 00 48 8D 35"]); // void __thiscall UTBLGameInstance::OnPreLoadMap(UTBLGameInstance *this,FString *param_1) CREATE_HOOK!(OnPreLoadMap,(game_instance: *mut c_void, map_url: *mut FString),{ - let original_ptr = map_url; - let url_w = unsafe { (*map_url).to_string() }; + let url_w = unsafe { (*map_url).copy_to_string().unwrap_or_else(|_| "UnknownMap".to_string()) }; crate::sinfo![f; "\x1b[32m{}\x1b[0m", url_w]; - // TODO: better check for server? if globals().world().is_none() && cli_args().is_server() { if !ENGINE_READY.load(Ordering::SeqCst) { ENGINE_READY.store(true, Ordering::SeqCst); @@ -144,7 +142,14 @@ CREATE_HOOK!(OnPreLoadMap,(game_instance: *mut c_void, map_url: *mut FString),{ } } - EVENT_SYSTEM.game_event_publisher.publish(GameEvent::MapChangeEvent(MapChange { new_map: url_w })); + + let map_url = url_w.as_str(); + let map_name = map_url.split('/').last().unwrap_or("UnknownMapName"); + + EVENT_SYSTEM.game_event_publisher.publish(GameEvent::MapChangeEvent(MapChange { + new_map_url: map_url.to_string(), + new_map_name: map_name.to_string() + })); }); // TODO: looks like this had major changes, needs real signature diff --git a/src/tools/cli_args.rs b/src/tools/cli_args.rs index df9f42c..348b6fb 100644 --- a/src/tools/cli_args.rs +++ b/src/tools/cli_args.rs @@ -100,17 +100,17 @@ pub struct CLIArgs { #[arg(long = "Port", default_value = "7777")] pub game_port: Option, - #[arg(long = "discord-channel-id")] - pub discord_channel_id: Option, - #[arg(long = "discord-admin-channel-id")] pub discord_admin_channel_id: Option, #[arg(long = "discord-general-channel-id")] pub discord_general_channel_id: Option, - #[arg(long = "censor-mode", default_value = "none")] - pub censor_mode: CensorArg, + #[arg(long = "discord-dashboard-channel-id")] + pub discord_dashboard_channel_id: Option, + + #[arg(long = "discord-event-log-channel-id")] + pub discord_event_log_channel_id: Option, #[arg(long = "discord-admin-role-id")] pub discord_admin_role_id: Option, @@ -119,8 +119,12 @@ pub struct CLIArgs { #[arg(long = "discord-bot-token")] pub discord_bot_token: Option, - #[arg(long = "discord-mention-on-admin", default_value = "true")] - pub discord_mention_on_admin: bool, + #[arg(long = "discord-mention-admins", default_value = "true")] + pub discord_mention_admins: bool, + + //TODO: Reimplement + #[arg(long = "censor-mode", default_value = "none")] + pub censor_mode: CensorArg, #[arg(long = "motd")] pub motd: Option, @@ -174,7 +178,7 @@ impl CLIArgs { // #[cfg(feature="discord_integration_old")] pub fn discord_enabled(&self) -> bool { - self.is_server() && self.discord_bot_token.is_some() && self.discord_channel_id.is_some() + self.is_server() && self.discord_bot_token.is_some() } pub fn is_server(&self) -> bool { @@ -351,13 +355,14 @@ mod tests { game_server_ping_port: None, game_server_query_port: None, game_port: None, - discord_channel_id: None, discord_admin_channel_id: None, discord_general_channel_id: None, - censor_mode: CensorArg::None, + discord_dashboard_channel_id: None, + discord_event_log_channel_id: None, discord_admin_role_id: None, discord_bot_token: None, - discord_mention_on_admin: false, + discord_mention_admins: false, + censor_mode: CensorArg::None, motd: None, extra_args: vec![], }