use chrono::{Local, Utc}; use poise::serenity_prelude as serenity; use rand::seq::IndexedRandom; use tracing::{info, warn}; use crate::Data; use crate::database::{self, VoiceActivity}; use crate::error::Result; use crate::lang; pub async fn handle_ready(_ctx: &serenity::Context) -> Result<()> { info!("{}", lang::L_EV_READY); Ok(()) } fn format_duration(seconds: f64) -> String { let total = seconds as u64; let hours = total / 3600; let minutes = (total % 3600) / 60; let secs = total % 60; if hours > 0 { format!("{hours}h {minutes}m {secs}s") } else if minutes > 0 { format!("{minutes}m {secs}s") } else { format!("{secs}s") } } async fn play_tts_in_channel( tts: std::sync::Arc, voice_manager: crate::voice::VoiceManager, guild_id: serenity::GuildId, channel_id: serenity::ChannelId, text: String, ) { let audio_data = match tts.synthesize(&text).await { Ok(bytes) => bytes, Err(e) => { warn!("Failed to synthesize TTS: {e}"); return; } }; let call = match voice_manager.connect(guild_id, channel_id).await { Ok(c) => c, Err(e) => { warn!("Failed to connect to voice for TTS: {e}"); return; } }; let input = crate::tts::pcm_bytes_to_input(audio_data); let mut call_lock = call.lock().await; if call_lock.current_channel().is_some() { call_lock.play_input(input); } } pub async fn handle_voice_state_update( ctx: &serenity::Context, data: &Data, old: Option, new: &serenity::VoiceState, ) -> Result<()> { let log_channel = data.config.status_channel_id.map(serenity::ChannelId::new); let current_ts = Utc::now().timestamp(); let current_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); let user_id = new.user_id; let guild_id = new.guild_id; // skip bots (Python behavior) let member = guild_id .and_then(|gid| ctx.cache.guild(gid)) .and_then(|g| g.members.get(&user_id).cloned()); if let Some(ref m) = member && m.user.bot { return Ok(()); } let member_name = member .as_ref() .map(|m| m.user.name.clone()) .unwrap_or_default(); let display_name = member .as_ref() .map(|m| m.display_name().to_string()) .unwrap_or_default(); // Pre-generate all 6 event phrases for this user (background, non-blocking). // First event per user pays ~120ms; subsequent events are instant from cache. { let tts = data.tts.clone(); let name = display_name.clone(); tokio::spawn(async move { tts.pre_generate(&name).await; }); } let now = current_ts as f64; let user_id_str = user_id.to_string(); let mut va = database::get_voice_activity(&data.db_pool, &user_id_str) .await? .unwrap_or(VoiceActivity { user_id: user_id_str.clone(), join_time: 0.0, total_active: 0.0, total_afk: 0.0, is_afk: 0, afk_start: 0.0, active_start: 0.0, channel_name: String::new(), last_updated: None, }); let old_channel = old.as_ref().and_then(|v| v.channel_id); let new_channel = new.channel_id; let mut tts_text: Option = None; let mut tts_channel: Option = None; // ── JOIN ── if old_channel.is_none() && let Some(cid) = new_channel { let is_afk = new.self_mute || new.self_deaf; va.join_time = now; va.total_active = 0.0; va.total_afk = 0.0; va.is_afk = if is_afk { 1 } else { 0 }; va.afk_start = if is_afk { now } else { 0.0 }; va.active_start = if !is_afk { now } else { 0.0 }; va.channel_name = guild_id .and_then(|gid| ctx.cache.guild(gid)) .map(|g| { g.channels .get(&cid) .map(|c| c.name.clone()) .unwrap_or_default() }) .unwrap_or_default(); database::upsert_voice_activity(&data.db_pool, &va).await?; info!("User {user_id} joined voice"); if let Some(lc) = log_channel { let msg = lang::fmt_log_ev_joined( ¤t_time, &member_name, current_ts, &va.channel_name, ); lc.say(ctx, &msg).await.ok(); } tts_text = Some(lang::fmt_ev_joined(&display_name)); tts_channel = Some(cid); } // ── LEAVE ── else if old_channel.is_some() && new_channel.is_none() { let ocid = old_channel.unwrap(); let channel_name = guild_id .and_then(|gid| ctx.cache.guild(gid)) .and_then(|g| g.channels.get(&ocid).map(|c| c.name.clone())) .unwrap_or_default(); if va.join_time > 0.0 { if va.is_afk == 1 { va.total_afk += now - va.afk_start; } else { va.total_active += (now - va.active_start).max(0.0); } let active_str = format_duration(va.total_active); let afk_str = format_duration(va.total_afk); // Accumulate to user_stats if in tracked guild if let Some(status_channel_id) = data.config.status_channel_id && let Some(gid) = guild_id { let is_tracked = ctx.cache.guild(gid) .map(|g| g.channels.contains_key(&serenity::ChannelId::new(status_channel_id))) .unwrap_or(false); if is_tracked { database::add_voice_time( &data.db_pool, &user_id_str, va.total_active, va.total_afk, ).await?; } } if let Some(lc) = log_channel { let msg = lang::fmt_log_ev_left( ¤t_time, &member_name, current_ts, &channel_name, &active_str, &afk_str, ); lc.say(ctx, &msg).await.ok(); } info!("User {user_id} left voice"); database::upsert_voice_activity(&data.db_pool, &va).await?; } else { if let Some(lc) = log_channel { let msg = lang::fmt_log_ev_left_no_data( ¤t_time, &member_name, current_ts, &channel_name, ); lc.say(ctx, &msg).await.ok(); } info!("User {user_id} left voice (no data)"); } tts_text = Some(lang::fmt_ev_left(&display_name)); tts_channel = Some(ocid); } // ── AFK STATE CHANGE (only for tracked users already in channel) ── else if new_channel.is_some() && va.join_time > 0.0 { let is_afk = new.self_mute || new.self_deaf; let was_afk = va.is_afk == 1; if was_afk && !is_afk { // Return from AFK let afk_duration = now - va.afk_start; va.total_afk += afk_duration; va.is_afk = 0; va.afk_start = 0.0; va.active_start = now; database::upsert_voice_activity(&data.db_pool, &va).await?; let afk_str = format_duration(afk_duration); if let Some(lc) = log_channel { let msg = lang::fmt_log_ev_activity( ¤t_time, &member_name, current_ts, &va.channel_name, &afk_str, ); lc.say(ctx, &msg).await.ok(); } info!("User {user_id} returned from AFK"); } else if !was_afk && is_afk { // Went AFK let active_duration = if va.active_start > 0.0 { (now - va.active_start).max(0.0) } else { 0.0 }; va.total_active += active_duration; va.is_afk = 1; va.afk_start = now; va.active_start = 0.0; database::upsert_voice_activity(&data.db_pool, &va).await?; let active_str = format_duration(active_duration); let activity = if new.self_mute { lang::L_EV_WORD_MUTED } else { lang::L_EV_WORD_DEAFENED }; if let Some(lc) = log_channel { let msg = lang::fmt_log_ev_unactivity( ¤t_time, &member_name, current_ts, &va.channel_name, activity, &active_str, ); lc.say(ctx, &msg).await.ok(); } info!("User {user_id} went AFK"); } } // ── TTS for mute/deafen (deaf takes priority with early return) ── if old.as_ref().is_some_and(|v| v.self_deaf) != new.self_deaf { let phrase = if new.self_deaf { lang::fmt_ev_deafened(&display_name) } else { lang::fmt_ev_undeafened(&display_name) }; if let Some(gid) = guild_id && let Some(cid) = tts_channel.or(new_channel) { tokio::spawn(play_tts_in_channel( data.tts.clone(), data.voice_manager.clone(), gid, cid, phrase, )); } return Ok(()); } if old.as_ref().is_some_and(|v| v.self_mute) != new.self_mute { let phrase = if new.self_mute { lang::fmt_ev_muted(&display_name) } else { lang::fmt_ev_unmuted(&display_name) }; if let Some(gid) = guild_id && let Some(cid) = tts_channel.or(new_channel) { tokio::spawn(play_tts_in_channel( data.tts.clone(), data.voice_manager.clone(), gid, cid, phrase, )); } return Ok(()); } // ── Play join/leave TTS if no mute/deafen change ── if let (Some(text), Some(cid)) = (tts_text, tts_channel) && let Some(gid) = guild_id { tokio::spawn(play_tts_in_channel( data.tts.clone(), data.voice_manager.clone(), gid, cid, text, )); } Ok(()) } pub async fn handle_message( ctx: &serenity::Context, data: &Data, new_message: &serenity::Message, ) -> Result<()> { // Ignore own messages and other bots if new_message.author.id == ctx.cache.current_user().id || new_message.author.bot { return Ok(()); } // Track message count in the tracked guild if let Some(status_channel_id) = data.config.status_channel_id && let Some(guild_id) = new_message.guild_id { // Check if the status channel is in the same guild as the message let is_tracked_guild = ctx.cache.guild(guild_id) .map(|g| g.channels.contains_key(&serenity::ChannelId::new(status_channel_id))) .unwrap_or(false); if is_tracked_guild { database::increment_message_count(&data.db_pool, &new_message.author.id.to_string()) .await?; } } let content_lower = new_message.content.to_lowercase(); let response_to_send = { let triggers = data.triggers.lock().await; let mut chosen = None; for (_trigger, keywords, responses) in triggers.iter() { if keywords.iter().any(|k| content_lower.contains(k)) { chosen = responses.choose(&mut rand::rng()).cloned(); break; } } chosen }; if let Some(response) = response_to_send { new_message.channel_id.say(ctx, response).await.ok(); } let is_mention = new_message.mentions_user_id(ctx.cache.current_user().id); let has_keyword = content_lower.contains(&data.config.assistant_keyword); if is_mention || has_keyword { // Spawn the LLM call in a background task so it doesn't block the // event loop. Discord interactions must be answered within 3 s; a // 5–10 s OpenRouter call would make every subsequent interaction // expire with "Unknown interaction". let http = ctx.http.clone(); let channel_id = new_message.channel_id; let _reply_to_id = new_message.id; let bot_id = ctx.cache.current_user().id; let (api_url, api_key, model) = data.config.resolve_llm(); let assistant_prompt = lang::ASSISTANT_SYSTEM_PROMPT.to_string(); // Build message history synchronously (fast path). let mut messages = Vec::new(); let mut current_msg = Some(new_message.clone()); for _ in 0..5 { if let Some(msg) = current_msg { let role = if msg.author.id == bot_id { "assistant" } else { "user" }; messages.push((role.to_string(), msg.content.clone())); current_msg = match msg.message_reference.and_then(|r| r.message_id) { Some(ref_id) => channel_id.message(&http, ref_id).await.ok(), None => None, }; } else { break; } } messages.reverse(); tokio::spawn(async move { let client = crate::openrouter::OpenRouterClient::new(api_url, api_key, model); if let Ok(response) = client.chat_with_history(&assistant_prompt, messages).await { let _ = channel_id.say(&http, response).await; } }); } Ok(()) }