From cd8ed8bc5793985df2c1692072a932ceee517b95 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 8 Jul 2026 02:13:35 +0200 Subject: OPEN THE RUST GATES Signed-off-by: salmonik --- src/events/mod.rs | 368 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 src/events/mod.rs (limited to 'src/events/mod.rs') diff --git a/src/events/mod.rs b/src/events/mod.rs new file mode 100644 index 0000000..457ebcd --- /dev/null +++ b/src/events/mod.rs @@ -0,0 +1,368 @@ +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( + _ctx: &serenity::Context, + data: &Data, + guild_id: serenity::GuildId, + channel_id: serenity::ChannelId, + text: &str, +) { + let tts_url = format!( + "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", + crate::commands::tts::urlencoding(text), + ); + + let audio_data = match data.http_client.get(&tts_url).send().await { + Ok(resp) => match resp.bytes().await { + Ok(b) => b, + Err(e) => { + warn!("Failed to read TTS audio bytes: {e}"); + return; + } + }, + Err(e) => { + warn!("Failed to fetch TTS audio: {e}"); + return; + } + }; + + let call = match data.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 = match crate::voice::decode_audio(&audio_data) { + Ok(i) => i, + Err(e) => { + warn!("Failed to decode TTS audio: {e}"); + return; + } + }; + + 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 { + if 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(); + + 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() && new_channel.is_some() { + let cid = new_channel.unwrap(); + 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 = format!( + "[{current_time}] {member_name} dołączył na {} ", + 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); + + if let Some(lc) = log_channel { + let msg = format!( + "[{current_time}] {member_name} rozłączył się z {} \n\ + Aktywny: {active_str} | AFK: {afk_str}", + channel_name, + ); + 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 = format!( + "[{current_time}] {member_name} rozłączył się z {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); + // Python behavior: after.self_mute is always false here so always "odgłuchnął" + let activity = if new.self_mute { + "odciszył" + } else { + "odgłuchnął" + }; + + if let Some(lc) = log_channel { + let msg = format!( + "[{current_time}] {member_name} {activity} się na {} \n\ + AFK: {afk_str}", + va.channel_name, + ); + 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 { + "wyciszył" + } else { + "ogłuchnął" + }; + + if let Some(lc) = log_channel { + let msg = format!( + "[{current_time}] {member_name} {activity} się na {} \n\ + Aktywny: {active_str}", + va.channel_name, + ); + 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().map_or(false, |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 { + if let Some(cid) = tts_channel.or(new_channel) { + play_tts_in_channel(ctx, data, gid, cid, &phrase).await; + } + } + return Ok(()); + } + + if old.as_ref().map_or(false, |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 { + if let Some(cid) = tts_channel.or(new_channel) { + play_tts_in_channel(ctx, data, gid, cid, &phrase).await; + } + } + return Ok(()); + } + + // ── Play join/leave TTS if no mute/deafen change ── + if let (Some(text), Some(cid)) = (tts_text, tts_channel) { + if let Some(gid) = guild_id { + play_tts_in_channel(ctx, data, gid, cid, &text).await; + } + } + + Ok(()) +} + +pub async fn handle_message( + ctx: &serenity::Context, + data: &Data, + new_message: &serenity::Message, +) -> Result<()> { + if new_message.author.id == ctx.cache.current_user().id { + return Ok(()); + } + + 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) && data.config.openrouter_api_key.is_some() { + let api_key = data.config.openrouter_api_key.clone().unwrap(); + let model = data.config.openrouter_model.clone().unwrap_or_else(|| { + "cognitivecomputations/dolphin-mistral-24b-venice-edition:free".into() + }); + + let client = crate::openrouter::OpenRouterClient::new(api_key, model); + let assistant_prompt = lang::ASSISTANT_SYSTEM_PROMPT; + + let mut messages = Vec::new(); + let mut current_msg = Some(new_message.clone()); + for _ in 0..5 { + if let Some(msg) = current_msg { + messages.push(("user".into(), msg.content.clone())); + current_msg = match msg.message_reference.and_then(|r| r.message_id) { + Some(ref_id) => msg.channel_id.message(ctx, ref_id).await.ok(), + None => None, + }; + } else { + break; + } + } + messages.reverse(); + + if let Ok(response) = client.chat_with_history(assistant_prompt, messages).await { + new_message.reply(ctx, response).await.ok(); + } + } + + Ok(()) +} -- cgit v1.2.3