diff options
| author | salmonik <raisersoft@proton.me> | 2026-07-08 02:13:35 +0200 |
|---|---|---|
| committer | salmonik <raisersoft@proton.me> | 2026-07-08 02:13:35 +0200 |
| commit | cd8ed8bc5793985df2c1692072a932ceee517b95 (patch) | |
| tree | 42d18a4140259b5c68b4dd05b7fcb398f78b8d7f /src/events/mod.rs | |
| parent | Merge pull request 'Voice related fixes, alongside simplifying assistant' (#5... (diff) | |
| download | senator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.tar.gz senator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.zip | |
OPEN THE RUST GATES
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to 'src/events/mod.rs')
| -rw-r--r-- | src/events/mod.rs | 368 |
1 files changed, 368 insertions, 0 deletions
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 @@ | |||
| 1 | use chrono::{Local, Utc}; | ||
| 2 | use poise::serenity_prelude as serenity; | ||
| 3 | use rand::seq::IndexedRandom; | ||
| 4 | use tracing::{info, warn}; | ||
| 5 | |||
| 6 | use crate::Data; | ||
| 7 | use crate::database::{self, VoiceActivity}; | ||
| 8 | use crate::error::Result; | ||
| 9 | use crate::lang; | ||
| 10 | |||
| 11 | pub async fn handle_ready(_ctx: &serenity::Context) -> Result<()> { | ||
| 12 | info!("{}", lang::L_EV_READY); | ||
| 13 | Ok(()) | ||
| 14 | } | ||
| 15 | |||
| 16 | fn format_duration(seconds: f64) -> String { | ||
| 17 | let total = seconds as u64; | ||
| 18 | let hours = total / 3600; | ||
| 19 | let minutes = (total % 3600) / 60; | ||
| 20 | let secs = total % 60; | ||
| 21 | if hours > 0 { | ||
| 22 | format!("{hours}h {minutes}m {secs}s") | ||
| 23 | } else if minutes > 0 { | ||
| 24 | format!("{minutes}m {secs}s") | ||
| 25 | } else { | ||
| 26 | format!("{secs}s") | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | async fn play_tts_in_channel( | ||
| 31 | _ctx: &serenity::Context, | ||
| 32 | data: &Data, | ||
| 33 | guild_id: serenity::GuildId, | ||
| 34 | channel_id: serenity::ChannelId, | ||
| 35 | text: &str, | ||
| 36 | ) { | ||
| 37 | let tts_url = format!( | ||
| 38 | "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", | ||
| 39 | crate::commands::tts::urlencoding(text), | ||
| 40 | ); | ||
| 41 | |||
| 42 | let audio_data = match data.http_client.get(&tts_url).send().await { | ||
| 43 | Ok(resp) => match resp.bytes().await { | ||
| 44 | Ok(b) => b, | ||
| 45 | Err(e) => { | ||
| 46 | warn!("Failed to read TTS audio bytes: {e}"); | ||
| 47 | return; | ||
| 48 | } | ||
| 49 | }, | ||
| 50 | Err(e) => { | ||
| 51 | warn!("Failed to fetch TTS audio: {e}"); | ||
| 52 | return; | ||
| 53 | } | ||
| 54 | }; | ||
| 55 | |||
| 56 | let call = match data.voice_manager.connect(guild_id, channel_id).await { | ||
| 57 | Ok(c) => c, | ||
| 58 | Err(e) => { | ||
| 59 | warn!("Failed to connect to voice for TTS: {e}"); | ||
| 60 | return; | ||
| 61 | } | ||
| 62 | }; | ||
| 63 | |||
| 64 | let input = match crate::voice::decode_audio(&audio_data) { | ||
| 65 | Ok(i) => i, | ||
| 66 | Err(e) => { | ||
| 67 | warn!("Failed to decode TTS audio: {e}"); | ||
| 68 | return; | ||
| 69 | } | ||
| 70 | }; | ||
| 71 | |||
| 72 | let mut call_lock = call.lock().await; | ||
| 73 | if call_lock.current_channel().is_some() { | ||
| 74 | call_lock.play_input(input); | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | pub async fn handle_voice_state_update( | ||
| 79 | ctx: &serenity::Context, | ||
| 80 | data: &Data, | ||
| 81 | old: Option<serenity::VoiceState>, | ||
| 82 | new: &serenity::VoiceState, | ||
| 83 | ) -> Result<()> { | ||
| 84 | let log_channel = data.config.status_channel_id.map(serenity::ChannelId::new); | ||
| 85 | let current_ts = Utc::now().timestamp(); | ||
| 86 | let current_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); | ||
| 87 | |||
| 88 | let user_id = new.user_id; | ||
| 89 | let guild_id = new.guild_id; | ||
| 90 | |||
| 91 | // skip bots (Python behavior) | ||
| 92 | let member = guild_id | ||
| 93 | .and_then(|gid| ctx.cache.guild(gid)) | ||
| 94 | .and_then(|g| g.members.get(&user_id).cloned()); | ||
| 95 | if let Some(ref m) = member { | ||
| 96 | if m.user.bot { | ||
| 97 | return Ok(()); | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | let member_name = member | ||
| 102 | .as_ref() | ||
| 103 | .map(|m| m.user.name.clone()) | ||
| 104 | .unwrap_or_default(); | ||
| 105 | let display_name = member | ||
| 106 | .as_ref() | ||
| 107 | .map(|m| m.display_name().to_string()) | ||
| 108 | .unwrap_or_default(); | ||
| 109 | |||
| 110 | let now = current_ts as f64; | ||
| 111 | |||
| 112 | let user_id_str = user_id.to_string(); | ||
| 113 | let mut va = database::get_voice_activity(&data.db_pool, &user_id_str) | ||
| 114 | .await? | ||
| 115 | .unwrap_or(VoiceActivity { | ||
| 116 | user_id: user_id_str.clone(), | ||
| 117 | join_time: 0.0, | ||
| 118 | total_active: 0.0, | ||
| 119 | total_afk: 0.0, | ||
| 120 | is_afk: 0, | ||
| 121 | afk_start: 0.0, | ||
| 122 | active_start: 0.0, | ||
| 123 | channel_name: String::new(), | ||
| 124 | last_updated: None, | ||
| 125 | }); | ||
| 126 | |||
| 127 | let old_channel = old.as_ref().and_then(|v| v.channel_id); | ||
| 128 | let new_channel = new.channel_id; | ||
| 129 | |||
| 130 | let mut tts_text: Option<String> = None; | ||
| 131 | let mut tts_channel: Option<serenity::ChannelId> = None; | ||
| 132 | |||
| 133 | // ── JOIN ── | ||
| 134 | if old_channel.is_none() && new_channel.is_some() { | ||
| 135 | let cid = new_channel.unwrap(); | ||
| 136 | let is_afk = new.self_mute || new.self_deaf; | ||
| 137 | |||
| 138 | va.join_time = now; | ||
| 139 | va.total_active = 0.0; | ||
| 140 | va.total_afk = 0.0; | ||
| 141 | va.is_afk = if is_afk { 1 } else { 0 }; | ||
| 142 | va.afk_start = if is_afk { now } else { 0.0 }; | ||
| 143 | va.active_start = if !is_afk { now } else { 0.0 }; | ||
| 144 | va.channel_name = guild_id | ||
| 145 | .and_then(|gid| ctx.cache.guild(gid)) | ||
| 146 | .map(|g| { | ||
| 147 | g.channels | ||
| 148 | .get(&cid) | ||
| 149 | .map(|c| c.name.clone()) | ||
| 150 | .unwrap_or_default() | ||
| 151 | }) | ||
| 152 | .unwrap_or_default(); | ||
| 153 | database::upsert_voice_activity(&data.db_pool, &va).await?; | ||
| 154 | info!("User {user_id} joined voice"); | ||
| 155 | |||
| 156 | if let Some(lc) = log_channel { | ||
| 157 | let msg = format!( | ||
| 158 | "[{current_time}] {member_name} dołączył na {} <t:{current_ts}:R>", | ||
| 159 | va.channel_name | ||
| 160 | ); | ||
| 161 | lc.say(ctx, &msg).await.ok(); | ||
| 162 | } | ||
| 163 | tts_text = Some(lang::fmt_ev_joined(&display_name)); | ||
| 164 | tts_channel = Some(cid); | ||
| 165 | } | ||
| 166 | // ── LEAVE ── | ||
| 167 | else if old_channel.is_some() && new_channel.is_none() { | ||
| 168 | let ocid = old_channel.unwrap(); | ||
| 169 | let channel_name = guild_id | ||
| 170 | .and_then(|gid| ctx.cache.guild(gid)) | ||
| 171 | .and_then(|g| g.channels.get(&ocid).map(|c| c.name.clone())) | ||
| 172 | .unwrap_or_default(); | ||
| 173 | |||
| 174 | if va.join_time > 0.0 { | ||
| 175 | if va.is_afk == 1 { | ||
| 176 | va.total_afk += now - va.afk_start; | ||
| 177 | } else { | ||
| 178 | va.total_active += (now - va.active_start).max(0.0); | ||
| 179 | } | ||
| 180 | |||
| 181 | let active_str = format_duration(va.total_active); | ||
| 182 | let afk_str = format_duration(va.total_afk); | ||
| 183 | |||
| 184 | if let Some(lc) = log_channel { | ||
| 185 | let msg = format!( | ||
| 186 | "[{current_time}] {member_name} rozłączył się z {} <t:{current_ts}:R>\n\ | ||
| 187 | Aktywny: {active_str} | AFK: {afk_str}", | ||
| 188 | channel_name, | ||
| 189 | ); | ||
| 190 | lc.say(ctx, &msg).await.ok(); | ||
| 191 | } | ||
| 192 | info!("User {user_id} left voice"); | ||
| 193 | |||
| 194 | database::upsert_voice_activity(&data.db_pool, &va).await?; | ||
| 195 | } else { | ||
| 196 | if let Some(lc) = log_channel { | ||
| 197 | let msg = format!( | ||
| 198 | "[{current_time}] {member_name} rozłączył się z {channel_name} <t:{current_ts}:R>" | ||
| 199 | ); | ||
| 200 | lc.say(ctx, &msg).await.ok(); | ||
| 201 | } | ||
| 202 | info!("User {user_id} left voice (no data)"); | ||
| 203 | } | ||
| 204 | |||
| 205 | tts_text = Some(lang::fmt_ev_left(&display_name)); | ||
| 206 | tts_channel = Some(ocid); | ||
| 207 | } | ||
| 208 | // ── AFK STATE CHANGE (only for tracked users already in channel) ── | ||
| 209 | else if new_channel.is_some() && va.join_time > 0.0 { | ||
| 210 | let is_afk = new.self_mute || new.self_deaf; | ||
| 211 | let was_afk = va.is_afk == 1; | ||
| 212 | |||
| 213 | if was_afk && !is_afk { | ||
| 214 | // Return from AFK | ||
| 215 | let afk_duration = now - va.afk_start; | ||
| 216 | va.total_afk += afk_duration; | ||
| 217 | va.is_afk = 0; | ||
| 218 | va.afk_start = 0.0; | ||
| 219 | va.active_start = now; | ||
| 220 | database::upsert_voice_activity(&data.db_pool, &va).await?; | ||
| 221 | |||
| 222 | let afk_str = format_duration(afk_duration); | ||
| 223 | // Python behavior: after.self_mute is always false here so always "odgłuchnął" | ||
| 224 | let activity = if new.self_mute { | ||
| 225 | "odciszył" | ||
| 226 | } else { | ||
| 227 | "odgłuchnął" | ||
| 228 | }; | ||
| 229 | |||
| 230 | if let Some(lc) = log_channel { | ||
| 231 | let msg = format!( | ||
| 232 | "[{current_time}] {member_name} {activity} się na {} <t:{current_ts}:R>\n\ | ||
| 233 | AFK: {afk_str}", | ||
| 234 | va.channel_name, | ||
| 235 | ); | ||
| 236 | lc.say(ctx, &msg).await.ok(); | ||
| 237 | } | ||
| 238 | info!("User {user_id} returned from AFK"); | ||
| 239 | } else if !was_afk && is_afk { | ||
| 240 | // Went AFK | ||
| 241 | let active_duration = if va.active_start > 0.0 { | ||
| 242 | (now - va.active_start).max(0.0) | ||
| 243 | } else { | ||
| 244 | 0.0 | ||
| 245 | }; | ||
| 246 | va.total_active += active_duration; | ||
| 247 | va.is_afk = 1; | ||
| 248 | va.afk_start = now; | ||
| 249 | va.active_start = 0.0; | ||
| 250 | database::upsert_voice_activity(&data.db_pool, &va).await?; | ||
| 251 | |||
| 252 | let active_str = format_duration(active_duration); | ||
| 253 | let activity = if new.self_mute { | ||
| 254 | "wyciszył" | ||
| 255 | } else { | ||
| 256 | "ogłuchnął" | ||
| 257 | }; | ||
| 258 | |||
| 259 | if let Some(lc) = log_channel { | ||
| 260 | let msg = format!( | ||
| 261 | "[{current_time}] {member_name} {activity} się na {} <t:{current_ts}:R>\n\ | ||
| 262 | Aktywny: {active_str}", | ||
| 263 | va.channel_name, | ||
| 264 | ); | ||
| 265 | lc.say(ctx, &msg).await.ok(); | ||
| 266 | } | ||
| 267 | info!("User {user_id} went AFK"); | ||
| 268 | } | ||
| 269 | } | ||
| 270 | |||
| 271 | // ── TTS for mute/deafen (deaf takes priority with early return) ── | ||
| 272 | if old.as_ref().map_or(false, |v| v.self_deaf) != new.self_deaf { | ||
| 273 | let phrase = if new.self_deaf { | ||
| 274 | lang::fmt_ev_deafened(&display_name) | ||
| 275 | } else { | ||
| 276 | lang::fmt_ev_undeafened(&display_name) | ||
| 277 | }; | ||
| 278 | if let Some(gid) = guild_id { | ||
| 279 | if let Some(cid) = tts_channel.or(new_channel) { | ||
| 280 | play_tts_in_channel(ctx, data, gid, cid, &phrase).await; | ||
| 281 | } | ||
| 282 | } | ||
| 283 | return Ok(()); | ||
| 284 | } | ||
| 285 | |||
| 286 | if old.as_ref().map_or(false, |v| v.self_mute) != new.self_mute { | ||
| 287 | let phrase = if new.self_mute { | ||
| 288 | lang::fmt_ev_muted(&display_name) | ||
| 289 | } else { | ||
| 290 | lang::fmt_ev_unmuted(&display_name) | ||
| 291 | }; | ||
| 292 | if let Some(gid) = guild_id { | ||
| 293 | if let Some(cid) = tts_channel.or(new_channel) { | ||
| 294 | play_tts_in_channel(ctx, data, gid, cid, &phrase).await; | ||
| 295 | } | ||
| 296 | } | ||
| 297 | return Ok(()); | ||
| 298 | } | ||
| 299 | |||
| 300 | // ── Play join/leave TTS if no mute/deafen change ── | ||
| 301 | if let (Some(text), Some(cid)) = (tts_text, tts_channel) { | ||
| 302 | if let Some(gid) = guild_id { | ||
| 303 | play_tts_in_channel(ctx, data, gid, cid, &text).await; | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | Ok(()) | ||
| 308 | } | ||
| 309 | |||
| 310 | pub async fn handle_message( | ||
| 311 | ctx: &serenity::Context, | ||
| 312 | data: &Data, | ||
| 313 | new_message: &serenity::Message, | ||
| 314 | ) -> Result<()> { | ||
| 315 | if new_message.author.id == ctx.cache.current_user().id { | ||
| 316 | return Ok(()); | ||
| 317 | } | ||
| 318 | |||
| 319 | let content_lower = new_message.content.to_lowercase(); | ||
| 320 | let response_to_send = { | ||
| 321 | let triggers = data.triggers.lock().await; | ||
| 322 | let mut chosen = None; | ||
| 323 | for (_trigger, keywords, responses) in triggers.iter() { | ||
| 324 | if keywords.iter().any(|k| content_lower.contains(k)) { | ||
| 325 | chosen = responses.choose(&mut rand::rng()).cloned(); | ||
| 326 | break; | ||
| 327 | } | ||
| 328 | } | ||
| 329 | chosen | ||
| 330 | }; | ||
| 331 | if let Some(response) = response_to_send { | ||
| 332 | new_message.channel_id.say(ctx, response).await.ok(); | ||
| 333 | } | ||
| 334 | |||
| 335 | let is_mention = new_message.mentions_user_id(ctx.cache.current_user().id); | ||
| 336 | let has_keyword = content_lower.contains(&data.config.assistant_keyword); | ||
| 337 | |||
| 338 | if (is_mention || has_keyword) && data.config.openrouter_api_key.is_some() { | ||
| 339 | let api_key = data.config.openrouter_api_key.clone().unwrap(); | ||
| 340 | let model = data.config.openrouter_model.clone().unwrap_or_else(|| { | ||
| 341 | "cognitivecomputations/dolphin-mistral-24b-venice-edition:free".into() | ||
| 342 | }); | ||
| 343 | |||
| 344 | let client = crate::openrouter::OpenRouterClient::new(api_key, model); | ||
| 345 | let assistant_prompt = lang::ASSISTANT_SYSTEM_PROMPT; | ||
| 346 | |||
| 347 | let mut messages = Vec::new(); | ||
| 348 | let mut current_msg = Some(new_message.clone()); | ||
| 349 | for _ in 0..5 { | ||
| 350 | if let Some(msg) = current_msg { | ||
| 351 | messages.push(("user".into(), msg.content.clone())); | ||
| 352 | current_msg = match msg.message_reference.and_then(|r| r.message_id) { | ||
| 353 | Some(ref_id) => msg.channel_id.message(ctx, ref_id).await.ok(), | ||
| 354 | None => None, | ||
| 355 | }; | ||
| 356 | } else { | ||
| 357 | break; | ||
| 358 | } | ||
| 359 | } | ||
| 360 | messages.reverse(); | ||
| 361 | |||
| 362 | if let Ok(response) = client.chat_with_history(assistant_prompt, messages).await { | ||
| 363 | new_message.reply(ctx, response).await.ok(); | ||
| 364 | } | ||
| 365 | } | ||
| 366 | |||
| 367 | Ok(()) | ||
| 368 | } | ||
