use clap::Parser;
use serenity::all::{
CommandInteraction, CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage,
Interaction,
};
use serenity::client::Context;
use serenity::model::gateway::Ready;
use serenity::prelude::{EventHandler, GatewayIntents};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
use tracing::{error, info};
use gromnie_runner::{ClientConfig, DiscordConsumer, EventBusManager, UptimeData, logging};
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
#[arg(short, long, action = clap::ArgAction::Count)]
debug: u8,
}
struct Handler {
target_channel_id: serenity::model::id::ChannelId,
action_tx: Arc<
tokio::sync::Mutex<
Option<tokio::sync::mpsc::UnboundedSender<gromnie_events::SimpleClientAction>>,
>,
>,
uptime_data: Arc<RwLock<UptimeData>>,
}
#[serenity::async_trait]
impl EventHandler for Handler {
async fn ready(&self, ctx: Context, ready: Ready) {
info!("Discord bot logged in as: {}", ready.user.name);
let uptime_command =
CreateCommand::new("uptime").description("Show bot uptime and in-game time");
if let Err(e) = ctx.http.create_global_command(&uptime_command).await {
error!("Failed to create uptime command: {}", e);
} else {
info!("Slash commands registered");
}
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::Command(command) = interaction {
info!(
"Received command {} from user {}",
command.data.name, command.user.id
);
match command.data.name.as_str() {
"uptime" => self.handle_uptime_command(&ctx, &command).await,
_ => {
let data = CreateInteractionResponseMessage::new()
.content("Unknown command".to_string());
let builder = CreateInteractionResponse::Message(data);
if let Err(e) = command.create_response(&ctx.http, builder).await {
error!("Failed to respond to unknown command: {}", e);
}
}
}
}
}
async fn message(&self, ctx: Context, msg: serenity::model::channel::Message) {
if msg.author.bot {
return;
}
if msg.guild_id.is_none() {
println!("[DM from {}]: {}", msg.author.name, msg.content);
return;
}
if msg.channel_id == self.target_channel_id {
let action_tx = self.action_tx.lock().await;
if let Some(ref tx) = *action_tx {
let game_message = format!("Discord: {}: {}", msg.author.name, msg.content);
if let Err(e) = tx.send(gromnie_events::SimpleClientAction::SendChatSay {
message: game_message,
}) {
error!("Failed to send Discord message to game: {}", e);
if let Err(reply_err) = msg.reply(&ctx.http, "⚠️ Failed to send your message to the game. The game client may be disconnected.").await {
error!("Failed to send error notification to Discord user: {}", reply_err);
}
} else {
info!("Forwarded Discord message to game");
}
} else {
if let Err(reply_err) = msg.reply(&ctx.http, "⚠️ Game client is not connected yet. Please wait or contact an administrator.").await {
error!("Failed to send not-connected notification to Discord user: {}", reply_err);
}
}
}
}
}
impl Handler {
async fn handle_uptime_command(&self, ctx: &Context, command: &CommandInteraction) {
let uptime_data = self.uptime_data.read().await;
let response_text = if let Some(ref ingame_fmt) = uptime_data.format_ingame_uptime() {
format!(
"**Bot Uptime:** {}\n**In-Game Time:** {}",
uptime_data.format_bot_uptime(),
ingame_fmt
)
} else {
format!(
"**Bot Uptime:** {}\n**In-Game Time:** Not logged in yet",
uptime_data.format_bot_uptime()
)
};
let data = CreateInteractionResponseMessage::new().content(response_text);
let builder = CreateInteractionResponse::Message(data);
if let Err(e) = command.create_response(&ctx.http, builder).await {
error!("Failed to respond to uptime command: {}", e);
}
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
#[allow(unused_variables)]
let log_guard = logging::init_logging("discord")?;
let _cli = Cli::parse();
let discord_token = std::env::var("DISCORD_TOKEN").map_err(|_| "DISCORD_TOKEN not provided")?;
let channel_id_u64 = std::env::var("DISCORD_CHANNEL_ID")
.ok()
.and_then(|s| s.parse().ok())
.ok_or("DISCORD_CHANNEL_ID not provided")?;
let game_host = std::env::var("GAME_SERVER_HOST").unwrap_or_else(|_| "localhost".to_string());
let game_port = std::env::var("GAME_SERVER_PORT").unwrap_or_else(|_| "9000".to_string());
let game_address = format!("{}:{}", game_host, game_port);
let game_username = std::env::var("GAME_ACCOUNT").map_err(|_| "GAME_ACCOUNT not provided")?;
let game_password = std::env::var("GAME_PASSWORD").map_err(|_| "GAME_PASSWORD not provided")?;
let http = Arc::new(serenity::http::Http::new(&discord_token));
let channel_id = serenity::model::id::ChannelId::new(channel_id_u64);
match http.get_user(serenity::model::id::UserId::new(1)).await {
Ok(_) => {
info!("Discord authentication successful");
}
Err(_) => {
info!("Discord token validated");
}
}
let (_client_event_tx, _client_event_rx) =
tokio::sync::mpsc::unbounded_channel::<gromnie_events::ProtocolEvent>();
let (action_tx_channel, mut action_tx_rx) = tokio::sync::mpsc::unbounded_channel::<
tokio::sync::mpsc::UnboundedSender<gromnie_events::SimpleClientAction>,
>();
let (_shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let uptime_data = Arc::new(RwLock::new(UptimeData {
bot_start: Instant::now(),
ingame_start: None,
}));
let config = ClientConfig {
id: 0,
address: game_address,
account_name: game_username,
password: game_password,
reconnect: false,
character_name: None,
};
let event_bus_manager = Arc::new(EventBusManager::new(100));
let http_clone = http.clone();
let uptime_data_clone = uptime_data.clone();
let _client_handle = tokio::spawn(gromnie_runner::run_client_with_action_channel(
config,
event_bus_manager,
move |action_tx| {
DiscordConsumer::new_with_uptime(
action_tx.clone(),
http_clone.clone(),
channel_id,
uptime_data_clone.clone(),
)
},
action_tx_channel,
shutdown_rx,
));
let action_tx_arc = Arc::new(tokio::sync::Mutex::new(None));
match tokio::time::timeout(tokio::time::Duration::from_secs(5), action_tx_rx.recv()).await {
Ok(Some(action_tx)) => {
info!("Game client connected");
*action_tx_arc.lock().await = Some(action_tx);
}
_ => {
error!("Failed to receive action_tx from game client task");
}
}
let uptime_data_periodic = uptime_data.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(300)); loop {
interval.tick().await;
let data = uptime_data_periodic.read().await;
let bot_uptime = data.format_bot_uptime();
if let Some(ref ingame_fmt) = data.format_ingame_uptime() {
info!("Uptime - Bot: {} | In-game: {}", bot_uptime, ingame_fmt);
} else {
info!("Bot uptime: {} (not in-game yet)", bot_uptime);
}
}
});
info!("Starting Discord bot gateway connection");
let mut discord_client = serenity::client::Client::builder(
&discord_token,
GatewayIntents::DIRECT_MESSAGES
| GatewayIntents::MESSAGE_CONTENT
| GatewayIntents::GUILD_MESSAGES,
)
.event_handler(Handler {
target_channel_id: channel_id,
action_tx: action_tx_arc,
uptime_data: uptime_data.clone(),
})
.await
.map_err(|e| format!("Failed to create Discord client: {}", e))?;
tokio::select! {
result = discord_client.start() => {
match result {
Ok(_) => info!("Discord client disconnected"),
Err(e) => error!("Discord client error: {}", e),
}
}
_ = tokio::signal::ctrl_c() => {
info!("Received Ctrl+C, shutting down gracefully...");
}
}
info!("Discord bot shut down");
Ok(())
}