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/commands/ai.rs | 76 ++++ src/commands/autocomplete.rs | 128 ++++++ src/commands/cron.rs | 155 ++++++++ src/commands/math.rs | 77 ++++ src/commands/mod.rs | 26 ++ src/commands/music.rs | 414 +++++++++++++++++++ src/commands/qr.rs | 87 ++++ src/commands/trigger.rs | 199 ++++++++++ src/commands/tts.rs | 106 +++++ src/commands/utilities.rs | 918 +++++++++++++++++++++++++++++++++++++++++++ src/commands/web.rs | 122 ++++++ 11 files changed, 2308 insertions(+) create mode 100644 src/commands/ai.rs create mode 100644 src/commands/autocomplete.rs create mode 100644 src/commands/cron.rs create mode 100644 src/commands/math.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/music.rs create mode 100644 src/commands/qr.rs create mode 100644 src/commands/trigger.rs create mode 100644 src/commands/tts.rs create mode 100644 src/commands/utilities.rs create mode 100644 src/commands/web.rs (limited to 'src/commands') diff --git a/src/commands/ai.rs b/src/commands/ai.rs new file mode 100644 index 0000000..a75f164 --- /dev/null +++ b/src/commands/ai.rs @@ -0,0 +1,76 @@ +use crate::Data; +use crate::error::Error; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// Query the AI senator via OpenRouter +#[poise::command(slash_command)] +pub async fn senator( + ctx: Context<'_>, + #[description = "Wiadomość do wysłania"] text: String, + #[description = "Przemówić w kanale głosowym?"] tts_vc: Option, +) -> crate::error::Result<()> { + ctx.defer().await?; + + let api_key = ctx + .data() + .config + .openrouter_api_key + .as_ref() + .ok_or_else(|| Error::Custom(lang::L_OPENROUTER_NO_KEY.into()))?; + let model = ctx + .data() + .config + .openrouter_model + .as_deref() + .unwrap_or("cognitivecomputations/dolphin-mistral-24b-venice-edition:free"); + + let client = crate::openrouter::OpenRouterClient::new(api_key.clone(), model.to_string()); + let response = client.chat(lang::SENATOR_SYSTEM_PROMPT, &text).await?; + + if tts_vc.unwrap_or(false) { + let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + let channel_id = ctx + .serenity_context() + .cache + .guild(guild_id) + .and_then(|g| { + g.voice_states + .get(&ctx.author().id) + .and_then(|vs| vs.channel_id) + }) + .ok_or_else(|| lang::err_not_in_voice())?; + + let tts_url = format!( + "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl=pl&client=tw-ob", + crate::commands::tts::urlencoding(&response) + ); + let audio_data = ctx + .data() + .http_client + .get(&tts_url) + .send() + .await? + .bytes() + .await?; + + let call = ctx + .data() + .voice_manager + .connect(guild_id, channel_id) + .await?; + let input = crate::voice::decode_audio(&audio_data)?; + let mut call_lock = call.lock().await; + if call_lock.current_channel().is_some() { + call_lock.play_input(input); + } + } + + ctx.say(response).await?; + Ok(()) +} + +pub fn commands() -> Vec> { + vec![senator()] +} diff --git a/src/commands/autocomplete.rs b/src/commands/autocomplete.rs new file mode 100644 index 0000000..497aec9 --- /dev/null +++ b/src/commands/autocomplete.rs @@ -0,0 +1,128 @@ +use poise::serenity_prelude as serenity; + +use crate::Data; +use crate::copyparty::{html_decode, percent_decode}; +use crate::error::Error; + +type Context<'a> = poise::Context<'a, Data, Error>; + +const MAX_CHOICE_LEN: usize = 99; + +fn filename_label(raw_path: &str) -> Option { + let raw_name = raw_path.rsplit('/').next()?; + if raw_name.is_empty() { + return None; + } + let decoded = html_decode(&percent_decode(raw_name)); + if decoded.len() > MAX_CHOICE_LEN { + let mut s: String = decoded.chars().take(MAX_CHOICE_LEN - 1).collect(); + s.push('\u{2026}'); + Some(s) + } else { + Some(decoded) + } +} + +pub async fn audio_file<'a>( + ctx: Context<'_>, + partial: &'a str, +) -> Vec { + eprintln!("[DEBUG] autocomplete audio_file(partial={partial:?})"); + let results = ctx + .data() + .copyparty + .search(partial, 25, &["mp3", "wav", "ogg", "flac", "m4a", "opus"]) + .await; + match results { + Ok(hits) => { + eprintln!("[DEBUG] audio_file got {} raw hits", hits.len()); + let mut choices = Vec::new(); + for (i, hit) in hits.into_iter().enumerate() { + let rp = match &hit.rp { + Some(p) => p.clone(), + None => continue, + }; + eprintln!( + "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", + hit.url, hit.name, hit.path + ); + let Some(label) = filename_label(&rp) else { + continue; + }; + ctx.data() + .ac_stash(ctx.author().id, label.clone(), hit) + .await; + eprintln!("[DEBUG] -> cached as {label:?}"); + choices.push(serenity::AutocompleteChoice::new(label.clone(), rp)); + if choices.len() >= 25 { + break; + } + } + eprintln!("[DEBUG] audio_file returning {} choices", choices.len()); + choices + } + Err(e) => { + eprintln!("[DEBUG] audio_file search failed: {e}"); + vec![] + } + } +} + +pub async fn any_file<'a>(ctx: Context<'_>, partial: &'a str) -> Vec { + eprintln!("[DEBUG] autocomplete any_file(partial={partial:?})"); + let results = ctx.data().copyparty.search(partial, 25, &[]).await; + match results { + Ok(hits) => { + eprintln!("[DEBUG] any_file got {} raw hits", hits.len()); + let mut choices = Vec::new(); + for (i, hit) in hits.into_iter().enumerate() { + let rp = match &hit.rp { + Some(p) => p.clone(), + None => continue, + }; + eprintln!( + "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", + hit.url, hit.name, hit.path + ); + let Some(label) = filename_label(&rp) else { + continue; + }; + ctx.data() + .ac_stash(ctx.author().id, label.clone(), hit) + .await; + eprintln!("[DEBUG] -> cached as {label:?}"); + choices.push(serenity::AutocompleteChoice::new(label.clone(), label)); + if choices.len() >= 25 { + break; + } + } + eprintln!("[DEBUG] any_file returning {} choices", choices.len()); + choices + } + Err(e) => { + eprintln!("[DEBUG] any_file search failed: {e}"); + vec![] + } + } +} + +pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec { + eprintln!("[DEBUG] autocomplete trigger_name(partial={partial:?})"); + let db_pool = &ctx.data().db_pool; + match crate::database::list_triggers(db_pool).await { + Ok(triggers) => { + let names: Vec<_> = triggers + .into_iter() + .map(|t| t.name) + .filter(|name| partial.is_empty() || name.starts_with(partial)) + .take(25) + .collect(); + eprintln!("[DEBUG] trigger_name returning {} names", names.len()); + names + } + Err(e) => { + eprintln!("[DEBUG] trigger_name query failed: {e}"); + vec![] + } + } +} diff --git a/src/commands/cron.rs b/src/commands/cron.rs new file mode 100644 index 0000000..48f6ff1 --- /dev/null +++ b/src/commands/cron.rs @@ -0,0 +1,155 @@ +use poise::serenity_prelude as serenity; + +use super::autocomplete::audio_file; +use crate::Data; +use crate::database; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// Cron job management +#[poise::command( + slash_command, + subcommands("dodaj_audio", "dodaj_wiadomość", "pokaż", "usuń", "pauza", "wznów",) +)] +pub async fn cron(_ctx: Context<'_>) -> Result<()> { + Ok(()) +} + +/// Schedule audio playback +#[poise::command(slash_command)] +pub async fn dodaj_audio( + ctx: Context<'_>, + #[description = "Cron expression (e.g. \"37 21 * * *\" = every day at 21:37)"] cron_expression: String, + #[description = "Target voice channel"] channel: serenity::Channel, + #[description = "Copyparty URL of audio file"] + #[autocomplete = "audio_file"] + audio_filename: String, +) -> Result<()> { + let channel_id = match channel { + serenity::Channel::Guild(ch) => ch.id.get() as i64, + _ => return Err(lang::err_bad_channel_type()), + }; + + database::add_cron_job( + &ctx.data().db_pool, + &cron_expression, + "audio", + channel_id, + Some(&audio_filename), + None, + ) + .await?; + + ctx.say(lang::fmt_cron_audio_queued(&cron_expression)) + .await?; + Ok(()) +} + +/// Schedule a periodic message +#[poise::command(slash_command)] +pub async fn dodaj_wiadomość( + ctx: Context<'_>, + #[description = "Cron expression (e.g. \"37 21 * * *\" = every day at 21:37)"] cron_expression: String, + #[description = "Docelowy kanał tekstowy"] channel: serenity::Channel, + #[description = "Zawartość wiadomości"] message: String, +) -> Result<()> { + let channel_id = match channel { + serenity::Channel::Guild(ch) => ch.id.get() as i64, + _ => return Err(lang::err_bad_channel_type()), + }; + + database::add_cron_job( + &ctx.data().db_pool, + &cron_expression, + "wiadomość", + channel_id, + None, + Some(&message), + ) + .await?; + + ctx.say(lang::L_CRON_QUEUED).await?; + Ok(()) +} + +/// List all cron jobs +#[poise::command(slash_command)] +pub async fn pokaż(ctx: Context<'_>) -> Result<()> { + let jobs = database::list_cron_jobs(&ctx.data().db_pool).await?; + if jobs.is_empty() { + ctx.say(lang::L_CRON_NONE).await?; + return Ok(()); + } + + let mut embed = serenity::CreateEmbed::default() + .title(lang::L_CRON_TITLE) + .color(0x00aaff); + + for job in &jobs { + let status = if job.active == 1 { + lang::L_CRON_ACTIVE + } else { + lang::L_CRON_PAUSED + }; + let desc = match job.r#type.as_str() { + "audio" => lang::fmt_cron_label_audio(job.audio_filename.as_deref().unwrap_or("?")), + _ => lang::fmt_cron_label_msg(job.message.as_deref().unwrap_or("?")), + }; + embed = embed.field( + lang::fmt_cron_job_status(job.id, status), + lang::fmt_cron_field(&job.cron_expression, job.channel_id, &desc), + false, + ); + } + + ctx.send(poise::CreateReply::default().embed(embed)).await?; + Ok(()) +} + +/// Remove a cron job +#[poise::command(slash_command)] +pub async fn usuń( + ctx: Context<'_>, + #[description = "ID pracy do usunięcia"] job_id: i64, +) -> Result<()> { + if database::delete_cron_job(&ctx.data().db_pool, job_id).await? { + ctx.say(lang::fmt_cron_deleted(job_id)).await?; + } else { + ctx.say(lang::fmt_cron_not_found(job_id)).await?; + } + Ok(()) +} + +/// Pause a cron job +#[poise::command(slash_command)] +pub async fn pauza( + ctx: Context<'_>, + #[description = "ID pracy do zapauzowania"] job_id: i64, +) -> Result<()> { + if database::set_cron_job_active(&ctx.data().db_pool, job_id, false).await? { + ctx.say(lang::fmt_cron_paused(job_id)).await?; + } else { + ctx.say(lang::fmt_cron_not_found(job_id)).await?; + } + Ok(()) +} + +/// Resume a paused cron job +#[poise::command(slash_command)] +pub async fn wznów( + ctx: Context<'_>, + #[description = "ID pracy cron do wznowienia"] job_id: i64, +) -> Result<()> { + if database::set_cron_job_active(&ctx.data().db_pool, job_id, true).await? { + ctx.say(lang::fmt_cron_resumed(job_id)).await?; + } else { + ctx.say(lang::fmt_cron_not_found(job_id)).await?; + } + Ok(()) +} + +pub fn commands() -> Vec> { + vec![cron()] +} diff --git a/src/commands/math.rs b/src/commands/math.rs new file mode 100644 index 0000000..43a7de8 --- /dev/null +++ b/src/commands/math.rs @@ -0,0 +1,77 @@ +use crate::lang; +use crate::Data; +use crate::error::{Error, Result}; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// Evaluate a math expression via qalc +#[poise::command(slash_command)] +pub async fn math( + ctx: Context<'_>, + #[description = "Ekspresja matematyczn"] equation: String, +) -> Result<()> { + if equation.len() > 1000 { + ctx.say(lang::L_MATH_TOO_LONG).await?; + return Ok(()); + } + + if !equation.is_ascii() { + ctx.say(lang::L_MATH_NOT_ASCII).await?; + return Ok(()); + } + + let mut depth = 0i32; + for c in equation.chars() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + _ => {} + } + if depth < 0 { + ctx.say(lang::L_MATH_UNBALANCED).await?; + return Ok(()); + } + } + if depth != 0 { + ctx.say(lang::L_MATH_UNBALANCED).await?; + return Ok(()); + } + + let dangerous = [ + "eval", + "exec", + "os.", + "subprocess", + "import", + "__", + "system", + "shutil", + "typecast", + "shell", + "fork", + "execve", + ]; + let lower = equation.to_lowercase(); + if dangerous.iter().any(|d| lower.contains(d)) { + ctx.say(lang::L_MATH_DANGEROUS).await?; + return Ok(()); + } + + let output = tokio::process::Command::new("qalc") + .arg(&equation) + .output() + .await + .map_err(|e| Error::Custom(lang::fmt_math_qalc_err(&e)))?; + + let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if result.is_empty() { + ctx.say(lang::L_MATH_NO_RESULT).await?; + } else { + ctx.say(lang::fmt_math_result(&result)).await?; + } + Ok(()) +} + +pub fn commands() -> Vec> { + vec![math()] +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..9d6a1d0 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,26 @@ +pub mod music; +pub mod tts; +pub mod ai; +pub mod web; +pub mod math; +pub mod qr; +pub mod cron; +pub mod trigger; +pub mod utilities; +pub mod autocomplete; + +use crate::Data; + +pub fn all_commands() -> Vec> { + let mut cmds = Vec::new(); + cmds.extend(music::commands()); + cmds.extend(tts::commands()); + cmds.extend(ai::commands()); + cmds.extend(web::commands()); + cmds.extend(math::commands()); + cmds.extend(qr::commands()); + cmds.extend(cron::commands()); + cmds.extend(trigger::commands()); + cmds.extend(utilities::commands()); + cmds +} diff --git a/src/commands/music.rs b/src/commands/music.rs new file mode 100644 index 0000000..313577d --- /dev/null +++ b/src/commands/music.rs @@ -0,0 +1,414 @@ +use poise::serenity_prelude as serenity; +use tracing::{error, info}; + +use super::autocomplete::audio_file; +use crate::Data; +use crate::copyparty::{html_decode, percent_decode}; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +// ── Source classification ────────────────────────────────────────── + +/// What kind of audio source are we dealing with? +/// +/// This mirrors the Python original's split: `EXTERNAL_URL_PATTERN` +/// (youtube/soundcloud/bandcamp) goes through yt-dlp download, while +/// *every other* URL is streamed directly via ffmpeg (radio, HLS, +/// playlists, direct media files). +#[derive(Debug, Clone, PartialEq)] +enum SourceKind { + /// YouTube, SoundCloud, Twitch, Bandcamp — platforms that don't serve + /// raw audio at a direct URL. Needs yt-dlp to extract + download the + /// media to memory before decoding. + YtDlp, + /// Any HTTP(S) URL: internet radio (Icecast/Shoutcast), HLS `.m3u8`, + /// `.m3u`/`.pls` playlists, Copyparty files, Discord attachments, or + /// directly-accessible media files. Streamed via an ffmpeg subprocess + /// — NO full download. ffmpeg handles playlist parsing, Icecast + /// metadata, reconnection, and codec/container detection universally. + Stream, +} + +/// Play music from Copyparty, URL, or file upload +#[poise::command(slash_command)] +pub async fn grajcuj( + ctx: Context<'_>, + #[description = "Plik do odtworzenia"] + #[autocomplete = "audio_file"] + melodia: Option, + #[description = "Bezpośredni plik z twojego systemu"] file: Option, + #[description = "Chujowa kompresja!?"] compressed: Option, +) -> Result<()> { + let compressed = compressed.unwrap_or(false); + eprintln!( + "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}", + melodia, + file.as_ref().map(|f| &f.filename) + ); + let result = play_impl(ctx, melodia, file, compressed).await; + if let Err(e) = &result { + eprintln!("[DEBUG] play() error: {e}"); + error!("play command failed: {e}"); + } + result +} + +struct ResolvedSource { + url: String, + display_name: String, + kind: SourceKind, +} + +fn clean_name(raw: &str) -> String { + html_decode(&percent_decode(raw)) +} + +// ── URL classification helpers ────────────────────────────────────── + +/// URLs that require yt-dlp extraction (platforms that don't serve +/// raw audio at a direct URL). These MUST be downloaded first. +/// +/// Matches the Python original's `EXTERNAL_URL_PATTERN`: +/// `youtube|youtu.be|soundcloud|bandcamp` (plus twitch). +fn needs_ytdlp(url: &str) -> bool { + let lower = url.to_lowercase(); + lower.contains("youtube.com/") + || lower.contains("youtu.be/") + || lower.contains("twitch.tv/") + || lower.contains("soundcloud.com/") + || lower.contains("bandcamp.com/") +} + +/// Heuristic: does this URL *look* like a live internet radio stream? +/// (Used for logging / display only — dispatch is controlled by `SourceKind`.) +fn is_live_stream_hint(url: &str) -> bool { + let lower = url.to_lowercase(); + // Common Icecast/Shoutcast mount patterns + lower.contains("/stream") + || lower.contains("/live") + || lower.contains("/radio") + || lower.contains(";stream") + || lower.contains("icecast") + || lower.contains("shoutcast") + || lower.ends_with(".m3u8") + || lower.ends_with(".m3u") + || lower.ends_with(".pls") + || lower.contains(":8000/") // common Icecast port + || lower.contains(":8080/") // common alt port +} + +async fn resolve_source( + ctx: Context<'_>, + melodia: Option, + file: Option, +) -> Result { + // ── File attachment ────────────────────────────────────────── + // Discord attachment URLs are direct media file URLs — ffmpeg streams + // them without a full download. + if let Some(file) = file { + return Ok(ResolvedSource { + url: file.url.clone(), + display_name: file.filename.clone(), + kind: SourceKind::Stream, + }); + } + + let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?; + + // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─ + if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await { + let path = hit.url_path().unwrap_or(""); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let url = ctx.data().copyparty.build_url(&path); + let display_name = clean_name(hit.filename().unwrap_or(&name)); + return Ok(ResolvedSource { + url, + display_name, + kind: SourceKind::Stream, + }); + } + + // ── Direct Copyparty path (starts with '/') ────────────────── + if name.starts_with('/') { + let url = ctx.data().copyparty.build_url(&name); + let display_name = clean_name(name.rsplit('/').next().unwrap_or(&name)); + return Ok(ResolvedSource { + url, + display_name, + kind: SourceKind::Stream, + }); + } + + // ── Direct HTTP(S) URL ─────────────────────────────────────── + if name.starts_with("http://") || name.starts_with("https://") { + let url = name.clone(); + let display_name = clean_name(url.rsplit('/').next().unwrap_or(&url)); + + // yt-dlp platforms → download; everything else (radio, HLS, + // playlists, Copyparty, direct media) → ffmpeg stream. + let kind = if needs_ytdlp(&url) { + SourceKind::YtDlp + } else { + SourceKind::Stream + }; + return Ok(ResolvedSource { + url, + display_name, + kind, + }); + } + + // ── Fallback: search Copyparty ─────────────────────────────── + info!(query = %name, "searching copyparty for audio"); + let results = ctx + .data() + .copyparty + .search(&name, 5, &["mp3", "wav", "ogg", "flac", "m4a", "opus"]) + .await + .map_err(|e| { + error!(query = %name, error = %e, "copyparty search failed"); + Error::Custom(lang::fmt_music_search_failed(&e)) + })?; + let hit = results + .first() + .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_AUDIO_ON_CP.into()))?; + let path = hit + .url_path() + .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_URL_IN_RESULT.into()))?; + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let url = ctx.data().copyparty.build_url(&path); + let display_name = clean_name(hit.filename().unwrap_or("unknown")); + Ok(ResolvedSource { + url, + display_name, + kind: SourceKind::Stream, + }) +} + +/// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging. +fn redact_url(url: &str) -> &str { + let Some(q) = url.find('?') else { + return url; + }; + let before = &url[..q]; + let after = &url[q..]; + // Strip `?pw=...` or `&pw=...` + if after.strip_prefix("?pw=").is_some() { + return before; + } + if let Some(idx) = after.find("&pw=") { + return &url[..q + idx]; + } + url +} + +/// Strip tracking/playlist params (`&...`) from YouTube URLs. +/// yt-dlp only needs `?v=ID`; extra `&` params can cause failures. +pub(crate) fn sanitize_yt_url(url: &str) -> String { + let lower = url.to_lowercase(); + if lower.contains("youtube.com/") || lower.contains("youtu.be/") { + if let Some(pos) = url.find('&') { + return url[..pos].to_string(); + } + } + url.to_string() +} + +// ── Playlist resolution is handled by ffmpeg natively ────────────── +// ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment +// fetching, PLS/M3U entry parsing) — no manual resolution needed. +// This mirrors the Python bot's `FFmpegPCMAudio(source, ...)` approach. + +async fn play_impl( + ctx: Context<'_>, + melodia: Option, + file: Option, + compressed: bool, +) -> Result<()> { + ctx.defer().await?; + + let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + + let channel_id = ctx + .serenity_context() + .cache + .guild(guild_id) + .and_then(|g| { + g.voice_states + .get(&ctx.author().id) + .and_then(|vs| vs.channel_id) + }) + .ok_or_else(|| lang::err_not_in_voice())?; + + let src = resolve_source(ctx, melodia, file).await?; + + let call; + let input; + + if compressed { + // ── Compressed path: download → compress → decode ────────── + // Defer stopping current track until compression is done. + let audio_bytes = match src.kind { + SourceKind::YtDlp => { + let clean_url = sanitize_yt_url(&src.url); + info!(url = %clean_url, "downloading via yt-dlp for compression"); + crate::voice::ytdlp_download(&clean_url).await? + } + SourceKind::Stream => { + if is_live_stream_hint(&src.url) { + ctx.say(lang::L_MUSIC_CANT_COMPRESS_LIVE).await?; + // Fall through to normal streaming below + let live = true; + info!(url = %redact_url(&src.url), live, "live stream — compression skipped, streaming via ffmpeg"); + // Stop current, connect, stream + { + let mut map = ctx.data().music_tracks.lock().await; + if let Some(handle) = map.remove(&guild_id.get()) { + let _ = handle.stop(); + } + } + let (call_res, input_res) = tokio::join!( + ctx.data().voice_manager.connect(guild_id, channel_id), + crate::voice::ffmpeg_stream_input(&src.url) + ); + call = call_res?; + input = input_res?; + // skip the rest of the compressed path + let handle = { + let mut call_lock = call.lock().await; + call_lock.play_input(input) + }; + ctx.data() + .music_tracks + .lock() + .await + .insert(guild_id.get(), handle); + ctx.say(lang::fmt_music_playing(&src.display_name)).await?; + return Ok(()); + } + info!(url = %redact_url(&src.url), "downloading for compression"); + crate::voice::download_url(&ctx.data().http_client, &src.url).await? + } + }; + + info!("degrading audio ({} bytes)", audio_bytes.len()); + let degraded = crate::voice::degrade_audio(&audio_bytes).await?; + + // Deferred stop: only interrupt current track now that we're ready. + { + let mut map = ctx.data().music_tracks.lock().await; + if let Some(handle) = map.remove(&guild_id.get()) { + let _ = handle.stop(); + } + } + call = ctx + .data() + .voice_manager + .connect(guild_id, channel_id) + .await?; + input = degraded; + } else { + // ── Normal path: stop first, then stream/download ────────── + { + let mut map = ctx.data().music_tracks.lock().await; + if let Some(handle) = map.remove(&guild_id.get()) { + let _ = handle.stop(); + } + } + + match src.kind { + SourceKind::YtDlp => { + let clean_url = sanitize_yt_url(&src.url); + info!(url = %clean_url, "downloading via yt-dlp"); + let audio_bytes = crate::voice::ytdlp_download(&clean_url).await?; + let (call_res, input_res) = tokio::join!( + ctx.data().voice_manager.connect(guild_id, channel_id), + tokio::task::spawn_blocking(move || crate::voice::decode_audio(&audio_bytes)) + ); + call = call_res?; + input = input_res??; + } + SourceKind::Stream => { + let live = is_live_stream_hint(&src.url); + info!(url = %redact_url(&src.url), live, "streaming via ffmpeg subprocess"); + let (call_res, input_res) = tokio::join!( + ctx.data().voice_manager.connect(guild_id, channel_id), + crate::voice::ffmpeg_stream_input(&src.url) + ); + call = call_res?; + input = input_res?; + } + } + } + + let handle = { + let mut call_lock = call.lock().await; + call_lock.play_input(input) + }; + + ctx.data() + .music_tracks + .lock() + .await + .insert(guild_id.get(), handle); + + ctx.say(lang::fmt_music_playing(&src.display_name)).await?; + Ok(()) +} + +/// Stop playback and disconnect from voice +#[poise::command(slash_command)] +pub async fn stop(ctx: Context<'_>) -> Result<()> { + let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + ctx.data().voice_manager.disconnect(guild_id).await?; + ctx.data().music_tracks.lock().await.remove(&guild_id.get()); + ctx.send( + poise::CreateReply::default() + .content(lang::L_MUSIC_STOPPED) + .ephemeral(true), + ) + .await?; + Ok(()) +} + +/// Join your voice channel +#[poise::command(slash_command)] +pub async fn join(ctx: Context<'_>) -> Result<()> { + ctx.defer().await?; + let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + + let channel_id = ctx.serenity_context().cache.guild(guild_id).and_then(|g| { + g.voice_states + .get(&ctx.author().id) + .and_then(|vs| vs.channel_id) + }); + + match channel_id { + Some(cid) => match ctx.data().voice_manager.connect(guild_id, cid).await { + Ok(_) => { + ctx.say(lang::fmt_voice_joined(&cid)).await?; + } + Err(e) => { + ctx.say(lang::fmt_voice_join_failed(&e)).await?; + } + }, + None => { + ctx.say(lang::L_VOICE_USER_NOT_IN_VC).await?; + } + } + Ok(()) +} + +pub fn commands() -> Vec> { + vec![grajcuj(), stop(), join()] +} diff --git a/src/commands/qr.rs b/src/commands/qr.rs new file mode 100644 index 0000000..26232ee --- /dev/null +++ b/src/commands/qr.rs @@ -0,0 +1,87 @@ +use poise::serenity_prelude as serenity; + +use crate::Data; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// QR group +#[poise::command(slash_command, subcommands("dekodowanie", "generowanie",))] +pub async fn qr(_ctx: Context<'_>) -> Result<()> { + Ok(()) +} + +/// Generate a QR code from text +#[poise::command(slash_command)] +pub async fn generowanie( + ctx: Context<'_>, + #[description = "Zawartość kodu QR"] text: String, +) -> Result<()> { + ctx.defer().await?; + + let code = qrcode::QrCode::new(text.as_bytes())?; + let image = code.render::>().build(); + + let scaled = image::imageops::resize( + &image, + image.width() * 10, + image.height() * 10, + image::imageops::Nearest, + ); + + let mut buf = std::io::Cursor::new(Vec::new()); + scaled.write_to(&mut buf, image::ImageFormat::Png)?; + + ctx.send( + poise::CreateReply::default() + .content(lang::L_QR_GENERATED) + .attachment(serenity::CreateAttachment::bytes( + buf.into_inner(), + "qrcode.png", + )), + ) + .await?; + Ok(()) +} + +/// Decode a QR code from an uploaded image +#[poise::command(slash_command)] +pub async fn dekodowanie( + ctx: Context<'_>, + #[description = "Obraz zawierający kod QR"] image: serenity::Attachment, +) -> Result<()> { + ctx.defer().await?; + + let img_data = ctx + .data() + .http_client + .get(&image.url) + .send() + .await? + .bytes() + .await?; + let img = image::load_from_memory(&img_data)?; + let gray = img.to_luma8(); + + let decoded = decode_qr_simple(&gray); + match decoded { + Some(text) => { + ctx.say(lang::fmt_qr_decoded(&text)).await?; + } + None => { + ctx.say(lang::L_QR_DECODE_FAILED).await?; + } + } + Ok(()) +} + +fn decode_qr_simple(img: &image::ImageBuffer, Vec>) -> Option { + rxing::helpers::detect_in_luma(img.as_raw().to_vec(), img.width(), img.height(), None) + .ok() + .map(|r| r.getText().to_owned()) +} + +pub fn commands() -> Vec> { + vec![qr()] +} diff --git a/src/commands/trigger.rs b/src/commands/trigger.rs new file mode 100644 index 0000000..7453e03 --- /dev/null +++ b/src/commands/trigger.rs @@ -0,0 +1,199 @@ +use poise::serenity_prelude as serenity; + +use super::autocomplete::trigger_name; +use crate::Data; +use crate::database; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// Trigger keyword management +#[poise::command( + slash_command, + subcommands("create", "add", "remove", "delete", "list", "help",) +)] +pub async fn trigger(_ctx: Context<'_>) -> Result<()> { + Ok(()) +} + +/// Create a new trigger group +#[poise::command(slash_command)] +pub async fn create( + ctx: Context<'_>, + #[description = "Logical group name"] + #[autocomplete = "trigger_name"] + name: String, +) -> Result<()> { + match database::create_trigger(&ctx.data().db_pool, &name).await { + Ok(_) => { + ctx.data().reload_triggers().await; + ctx.say(lang::fmt_trigger_created(&name)).await?; + } + Err(e) => { + ctx.say(lang::fmt_trigger_failed(&e)).await?; + } + } + Ok(()) +} + +/// Add keywords and/or responses to a trigger group +#[poise::command(slash_command)] +pub async fn add( + ctx: Context<'_>, + #[description = "Existing group name"] + #[autocomplete = "trigger_name"] + name: String, + #[description = "Keywords separated by \\"] trigger_keywords: Option, + #[description = "Responses separated by \\"] response: Option, +) -> Result<()> { + let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) + .await? + .ok_or_else(|| lang::err_trigger_not_found())?; + + let mut modified = false; + if let Some(kw) = trigger_keywords { + for keyword in kw.split('\\') { + let k = keyword.trim(); + if !k.is_empty() { + database::add_keyword(&ctx.data().db_pool, t.id, k).await?; + modified = true; + } + } + } + if let Some(r) = response { + for resp in r.split('\\') { + let r = resp.trim(); + if !r.is_empty() { + database::add_response(&ctx.data().db_pool, t.id, r).await?; + modified = true; + } + } + } + + if modified { + ctx.data().reload_triggers().await; + } + ctx.say(lang::L_TRIGGER_UPDATED).await?; + Ok(()) +} + +/// Remove keyword or response from a group +#[poise::command(slash_command)] +pub async fn remove( + ctx: Context<'_>, + #[description = "Group name"] + #[autocomplete = "trigger_name"] + name: String, + #[description = "Keyword to remove"] trigger_keyword: Option, + #[description = "Response to remove"] response: Option, +) -> Result<()> { + let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) + .await? + .ok_or_else(|| lang::err_trigger_not_found())?; + + let mut modified = false; + if let Some(kw) = trigger_keyword { + database::remove_keyword(&ctx.data().db_pool, t.id, &kw).await?; + modified = true; + } + if let Some(r) = response { + database::remove_response(&ctx.data().db_pool, t.id, &r).await?; + modified = true; + } + + if modified { + ctx.data().reload_triggers().await; + } + ctx.say(lang::L_TRIGGER_UPDATED).await?; + Ok(()) +} + +/// Delete an entire trigger group +#[poise::command(slash_command)] +pub async fn delete( + ctx: Context<'_>, + #[description = "Group name"] + #[autocomplete = "trigger_name"] + name: String, +) -> Result<()> { + let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) + .await? + .ok_or_else(|| lang::err_trigger_not_found())?; + + database::delete_trigger(&ctx.data().db_pool, t.id).await?; + ctx.data().reload_triggers().await; + ctx.say(lang::fmt_trigger_deleted(&name)).await?; + Ok(()) +} + +/// List keywords and responses for a group +#[poise::command(slash_command)] +pub async fn list( + ctx: Context<'_>, + #[description = "Group name"] + #[autocomplete = "trigger_name"] + name: String, +) -> Result<()> { + let t = database::get_trigger_by_name(&ctx.data().db_pool, &name) + .await? + .ok_or_else(|| lang::err_trigger_not_found())?; + + let keywords = database::get_keywords(&ctx.data().db_pool, t.id).await?; + let responses = database::get_responses(&ctx.data().db_pool, t.id).await?; + + let mut embed = serenity::CreateEmbed::default() + .title(lang::fmt_trigger_title(&name)) + .color(0x00aaff); + + embed = embed.field( + "Keywords", + if keywords.is_empty() { + lang::L_TRIGGER_KW_NONE.into() + } else { + keywords + .iter() + .map(|r| format!("{r}")) + .collect::>() + .join("\n") + }, + false, + ); + embed = embed.field( + "Responses", + if responses.is_empty() { + lang::L_TRIGGER_KW_NONE.into() + } else { + responses + .iter() + .map(|r| format!("{r}")) + .collect::>() + .join("\n") + }, + false, + ); + + ctx.send(poise::CreateReply::default().embed(embed)).await?; + Ok(()) +} + +/// Display trigger command usage info +#[poise::command(slash_command)] +pub async fn help(ctx: Context<'_>) -> Result<()> { + let help = [ + "**Trigger Commands**", + "`/trigger create ` — Create new trigger group", + "`/trigger add ` — Add keywords/responses (separate with \\)", + "`/trigger remove ` — Remove keywords/responses", + "`/trigger delete ` — Delete entire group", + "`/trigger list ` — View group details", + ] + .join("\n"); + + ctx.say(help).await?; + Ok(()) +} + +pub fn commands() -> Vec> { + vec![trigger()] +} diff --git a/src/commands/tts.rs b/src/commands/tts.rs new file mode 100644 index 0000000..ee13596 --- /dev/null +++ b/src/commands/tts.rs @@ -0,0 +1,106 @@ +use poise::serenity_prelude as serenity; + +use crate::Data; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +#[derive(poise::ChoiceParameter)] +pub enum OutputMode { + #[name = "File"] + File, + #[name = "Voice Channel"] + Vc, +} + +/// Generate Google TTS (Polish) and output to file or voice channel +#[poise::command(slash_command)] +pub async fn tts( + ctx: Context<'_>, + #[description = "Text to synthesize"] text: String, + #[description = "Output destination (file or vc)"] output: Option, +) -> Result<()> { + ctx.defer().await?; + let lang = "pl"; + let tts_url = format!( + "https://translate.google.com/translate_tts?ie=UTF-8&q={}&tl={}&client=tw-ob", + urlencoding(&text), + lang + ); + + let output = output.unwrap_or(OutputMode::Vc); + + let audio_data = ctx + .data() + .http_client + .get(&tts_url) + .send() + .await? + .bytes() + .await?; + + match output { + OutputMode::File => { + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + let path = format!( + "{}/tts_{}.mp3", + crate::config::TEMP_PATH, + uuid::Uuid::new_v4() + ); + tokio::fs::write(&path, &audio_data).await?; + ctx.send( + poise::CreateReply::default() + .content(lang::L_TTS_SAVED) + .attachment(serenity::CreateAttachment::path(&path).await.unwrap()), + ) + .await?; + } + OutputMode::Vc => { + let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; + let channel_id = ctx + .serenity_context() + .cache + .guild(guild_id) + .and_then(|g| { + g.voice_states + .get(&ctx.author().id) + .and_then(|vs| vs.channel_id) + }) + .ok_or_else(|| lang::err_not_in_voice())?; + + let call = ctx + .data() + .voice_manager + .connect(guild_id, channel_id) + .await?; + let input = crate::voice::decode_audio(&audio_data)?; + let mut call_lock = call.lock().await; + if call_lock.current_channel().is_some() { + call_lock.play_input(input); + } + ctx.say(lang::L_TTS_PLAYING).await?; + } + } + Ok(()) +} + +pub fn urlencoding(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for &byte in s.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'~' => { + out.push(byte as char) + } + b' ' => out.push('+'), + _ => out.push_str(&format!("%{:02X}", byte)), + } + } + out +} + +pub fn commands() -> Vec> { + vec![tts()] +} diff --git a/src/commands/utilities.rs b/src/commands/utilities.rs new file mode 100644 index 0000000..10ce403 --- /dev/null +++ b/src/commands/utilities.rs @@ -0,0 +1,918 @@ +use poise::serenity_prelude as serenity; +use tracing::warn; + +use super::autocomplete::any_file; +use super::music::sanitize_yt_url; +use crate::Data; +use crate::copyparty::{html_decode, percent_decode}; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +// Above this size (or when `serwer` is forced) the result is uploaded to +// copyparty instead of being attached to the Discord reply. Discord's own +// attachment limit is 25 MiB for non-Nitro, but 10 MiB matches the Python +// original and keeps replies snappy. +const LARGE_FILE_THRESHOLD: usize = 10 * 1024 * 1024; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum FileType { + Video, + Audio, + Image, + Unknown, +} + +/// Classify a file by its extension. +/// +/// Mirrors Python's `mimetypes.guess_type`-based `filetype()` helper without +/// pulling in a mime crate: the set of extensions below covers everything the +/// original bot handled. +fn file_type(filename: &str) -> FileType { + let Some((_, ext)) = filename.rsplit_once('.') else { + return FileType::Unknown; + }; + if ext.is_empty() || ext.contains('/') { + return FileType::Unknown; + } + match ext.to_ascii_lowercase().as_str() { + "mp4" | "avi" | "mkv" | "mov" | "webm" | "flv" | "wmv" | "m4v" | "mpg" | "mpeg" | "3gp" + | "ts" => FileType::Video, + "mp3" | "wav" | "ogg" | "flac" | "m4a" | "opus" | "aac" | "wma" | "oga" => FileType::Audio, + "jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" | "tiff" | "tif" | "avif" => { + FileType::Image + } + _ => FileType::Unknown, + } +} + +/// Lowercased extension without the leading dot, or `None` if there is none. +fn ext_of(filename: &str) -> Option { + let (_, ext) = filename.rsplit_once('.')?; + if ext.is_empty() || ext.contains('/') { + None + } else { + Some(ext.to_ascii_lowercase()) + } +} + +/// Filename stem: everything before the final dot of the basename. +fn stem_of(filename: &str) -> &str { + let base = filename.rsplit('/').next().unwrap_or(filename); + match base.rsplit_once('.') { + Some((stem, _)) if !stem.is_empty() => stem, + _ => base, + } +} + +fn temp_path_for(name: &str) -> String { + format!("{}/{}", crate::config::TEMP_PATH, name) +} + +/// Best-effort removal of temp files; ignores missing files. +async fn cleanup(paths: &[&str]) { + for p in paths { + let _ = tokio::fs::remove_file(p).await; + } +} + +/// Resolve the input source — either an autocomplete-picked copyparty file or +/// a Discord attachment — downloading the bytes and returning them alongside +/// the original filename. +/// +/// Replies with an appropriate user-facing message and returns `Ok(None)` when +/// no valid source was supplied, so callers can early-return without sending a +/// second message. +async fn resolve_input( + ctx: &Context<'_>, + plik: Option<&str>, + zalacznik: Option<&serenity::Attachment>, +) -> Result, String)>> { + if let Some(name) = plik { + // Autocomplete-selected copyparty file: the choice value is the decoded + // filename, stashed into the per-user ac_cache by `any_file`. + let hit = match ctx.data().ac_lookup(ctx.author().id, name).await { + Some(h) => h, + None => { + ctx.say(lang::L_UTIL_PICK_FROM_AUTOCOMPLETE).await?; + return Ok(None); + } + }; + let path = hit.url_path().unwrap_or(""); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let full_url = ctx.data().copyparty.build_url(&path); + let resp = ctx.data().http_client.get(&full_url).send().await?; + if !resp.status().is_success() { + ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?; + return Ok(None); + } + let bytes = resp.bytes().await?.to_vec(); + return Ok(Some((bytes, name.to_string()))); + } + + if let Some(att) = zalacznik { + let resp = ctx.data().http_client.get(&att.url).send().await?; + if !resp.status().is_success() { + ctx.say(lang::L_UTIL_DOWNLOAD_FAILED).await?; + return Ok(None); + } + let bytes = resp.bytes().await?.to_vec(); + return Ok(Some((bytes, att.filename.clone()))); + } + + ctx.say(lang::L_UTIL_NEED_INPUT).await?; + Ok(None) +} + +/// Output extension the compressor produces for a given input. +/// +/// - Video keeps its container extension (ffmpeg re-muxes in place). +/// - Audio is always re-encoded to Opus in an `.ogg` container. +/// - JPEG/GIF keep their extension; other images are rewritten to `.jpg`. +fn compress_output_ext(ft: FileType, original: &str) -> String { + let orig = ext_of(original); + match ft { + FileType::Video => orig.unwrap_or_else(|| "mp4".to_string()), + FileType::Audio => "ogg".to_string(), + FileType::Image => match orig.as_deref() { + Some("jpg" | "jpeg" | "gif") => orig.unwrap(), + _ => "jpg".to_string(), + }, + FileType::Unknown => String::new(), + } +} + +/// Build the ffmpeg/magick command for extreme-low-quality compression. +/// Returns `None` for unsupported (`Unknown`) file types. +fn build_compress_command( + ft: FileType, + input: &str, + output: &str, +) -> Option { + match ft { + FileType::Video => { + let mut c = tokio::process::Command::new("ffmpeg"); + c.arg("-y") + .arg("-i") + .arg(input) + .arg("-c:v") + .arg("libx264") + .arg("-crf") + .arg("51") + .arg("-b:v") + .arg("100") + .arg("-vf") + .arg("fps=10,scale=trunc(iw*0.5/2)*2:trunc(ih*0.5/2)*2") + .arg("-preset") + .arg("ultrafast") + .arg("-c:a") + .arg("libopus") + .arg("-b:a") + .arg("500") + .arg(output); + Some(c) + } + FileType::Audio => { + let mut c = tokio::process::Command::new("ffmpeg"); + c.arg("-y") + .arg("-i") + .arg(input) + .arg("-c:a") + .arg("libopus") + .arg("-b:a") + .arg("500") + .arg("-ar") + .arg("12000") + .arg("-ac") + .arg("1") + .arg(output); + Some(c) + } + FileType::Image => { + let mut c = tokio::process::Command::new("magick"); + c.arg(input); + match ext_of(input).as_deref() { + Some("gif") => { + c.arg("-coalesce") + .arg("-resize") + .arg("30%") + .arg("-colors") + .arg("32") + .arg("-strip") + .arg("-layers") + .arg("Optimize"); + } + // jpg/jpeg stay jpg; other images are rewritten to .jpg by + // `compress_output_ext` and magick infers the encoder from the + // output extension. + _ => { + c.arg("-strip") + .arg("-resize") + .arg("30%") + .arg("-quality") + .arg("5"); + } + } + c.arg(output); + Some(c) + } + FileType::Unknown => None, + } +} + +enum DeliverKind { + Compress, + Convert, +} + +/// Compress a single in-memory blob (an attachment body) into a compressed +/// file, returning the encoded bytes and the output filename. +/// +/// Mirrors the per-attachment path of Python's `Compression` message command, +/// including sequential `TEMP_PATH` reuse so identical attachment stems don't +/// collide (each iteration writes, encodes, then `cleanup`s before the next). +async fn compress_attachment_bytes( + ft: FileType, + original: &str, + input_bytes: &[u8], +) -> Option<(Vec, String)> { + let stem = stem_of(original).to_string(); + let out_ext = compress_output_ext(ft, original); + let out_filename = format!("compressed_{stem}.{out_ext}"); + let input_path = temp_path_for(original); + let output_path = temp_path_for(&out_filename); + let cleanup_paths = [input_path.as_str(), output_path.as_str()]; + + if tokio::fs::write(&input_path, input_bytes).await.is_err() { + return None; + } + let mut cmd = match build_compress_command(ft, &input_path, &output_path) { + Some(c) => c, + None => { + cleanup(&cleanup_paths).await; + return None; + } + }; + let res = match cmd.output().await { + Ok(out) if out.status.success() => tokio::fs::read(&output_path).await.ok(), + _ => None, + }; + cleanup(&cleanup_paths).await; + res.map(|b| (b, out_filename)) +} + +/// Strip characters that Discord/coping filenames can't safely carry. Mirrors +/// Python's `Downloader._sanitize_filename`. +fn sanitize_filename(name: &str) -> String { + const BAD: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + name.chars() + .filter(|c| !BAD.contains(c)) + .collect::() + .trim() + .to_string() +} + +/// Extract `https://` URLs containing a path (host + `/...`) from a message. +/// Faithful port of Python's `Downloader.URL_PATTERN` regex used by the +/// message-context `download` command — only URLs with at least one `/` after +/// the host are matched, and `http://` links are intentionally ignored. +fn extract_urls(content: &str) -> Vec { + let bytes = content.as_bytes(); + let mut urls = Vec::new(); + let mut i = 0; + while let Some(pos) = content[i..].find("https://") { + let start = i + pos; + let after = start + "https://".len(); + + // Consume the host (until `/` or whitespace). + let mut j = after; + while j < bytes.len() && !bytes[j].is_ascii_whitespace() && bytes[j] != b'/' { + j += 1; + } + + if j < bytes.len() && bytes[j] == b'/' { + // Consume the rest of the URL (until whitespace). + let mut k = j; + while k < bytes.len() && !bytes[k].is_ascii_whitespace() { + k += 1; + } + urls.push(content[start..k].to_string()); + i = k; + } else { + // No path component — not matched by the regex; advance past it. + i = after; + } + } + urls +} + +/// Locate the single file yt-dlp produced in `dir`, picking the most recently +/// modified one (matches Python's `os.listdir`-first fallback behaviour while +/// being deterministic enough for the per-call unique subdir). +async fn find_downloaded_file(dir: &str) -> Option<(String, String)> { + let mut rd = tokio::fs::read_dir(dir).await.ok()?; + let mut newest: Option<(std::time::SystemTime, String)> = None; + while let Ok(Some(entry)) = rd.next_entry().await { + let meta = match entry.metadata().await { + Ok(m) => m, + Err(_) => continue, + }; + if !meta.is_file() { + continue; + } + let mtime = match meta.modified() { + Ok(t) => t, + Err(_) => continue, + }; + if let Some((t, _)) = &newest { + if *t >= mtime { + continue; + } + } + newest = Some((mtime, entry.path().to_string_lossy().into_owned())); + } + newest.map(|(_, p)| { + let fname = std::path::Path::new(&p) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + (p, fname) + }) +} + +/// Hand the produced download back to the user: attach when below the size +/// threshold (and upload isn't forced), otherwise push to copyparty and reply +/// with the download URL. Mirrors Python's `Downloader` slash `/download`. +async fn deliver_download( + ctx: &Context<'_>, + bytes: Vec, + filename: String, + catbox: bool, +) -> Result<()> { + if bytes.len() > LARGE_FILE_THRESHOLD || catbox { + match ctx.data().copyparty.upload(&filename, bytes).await { + Ok(url) => { + ctx.say(lang::fmt_download_uploaded(&url)).await?; + } + Err(e) => { + tracing::warn!("copyparty upload failed: {e}"); + ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; + } + } + } else { + ctx.send( + poise::CreateReply::default() + .attachment(serenity::CreateAttachment::bytes(bytes, filename)), + ) + .await?; + } + Ok(()) +} + +/// Run yt-dlp on `url` and deliver the resulting file. The caller is expected +/// to have already called `ctx.defer()`. Each invocation uses a fresh unique +/// subdirectory under `TEMP_PATH` (matching Python's per-call +/// `tempfile.TemporaryDirectory`) so concurrent downloads never collide. +async fn run_download( + ctx: &Context<'_>, + url: &str, + catbox: bool, + fformat: Option<&str>, + filename_user: Option<&str>, +) -> Result<()> { + let fmt = fformat.unwrap_or("mp4"); + let is_audio = matches!(fmt, "mp3" | "wav" | "m4a"); + let dl_dir = format!("{}/dl_{}", crate::config::TEMP_PATH, uuid::Uuid::new_v4()); + tokio::fs::create_dir_all(&dl_dir).await.ok(); + + let out_template = match filename_user { + Some(name) => { + // Custom filename: sanitize, cap at 100 chars, let yt-dlp append ext. + let clean: String = sanitize_filename(name).chars().take(100).collect(); + format!("{dl_dir}/{clean}.%(ext)s") + } + None => format!("{dl_dir}/%(title).100s.%(ext)s"), + }; + + let mut cmd = tokio::process::Command::new("yt-dlp"); + cmd.arg("-f") + .arg("bestaudio/bestvideo+bestaudio") + .arg("--merge-output-format") + .arg("mp4") + .arg("-o") + .arg(&out_template); + if is_audio { + cmd.arg("--extract-audio").arg("--audio-format").arg(fmt); + } + cmd.arg(sanitize_yt_url(url)); + + let output = cmd + .output() + .await + .map_err(|e| Error::Custom(lang::fmt_ytdlp_err(&e)))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Keep the full yt-dlp trace in the logs; the user only sees the + // short `fmt_download_failed` (translated) message. + warn!(url = %url, "yt-dlp failed:\n{}", stderr.trim_end()); + let _ = tokio::fs::remove_dir_all(&dl_dir).await; + ctx.say(lang::fmt_download_failed(&stderr)).await?; + return Ok(()); + } + + let Some((actual_path, basename)) = find_downloaded_file(&dl_dir).await else { + let _ = tokio::fs::remove_dir_all(&dl_dir).await; + ctx.say(lang::fmt_download_failed("no file produced")) + .await?; + return Ok(()); + }; + + // Determine the display filename for the upload/attachment. + let final_name = match filename_user { + Some(name) => { + let clean: String = sanitize_filename(name).chars().take(100).collect(); + let ext = std::path::Path::new(&basename) + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + format!("{clean}{ext}") + } + None => basename, + }; + + let bytes = match tokio::fs::read(&actual_path).await { + Ok(b) => b, + Err(e) => { + let _ = tokio::fs::remove_dir_all(&dl_dir).await; + ctx.say(lang::fmt_download_failed(&e.to_string())).await?; + return Ok(()); + } + }; + + let deliver_result = deliver_download(ctx, bytes, final_name, catbox).await; + let _ = tokio::fs::remove_dir_all(&dl_dir).await; + deliver_result +} + +/// Hand the produced bytes back to the user. +/// +/// Below the size threshold (and when upload isn't forced) the result is +/// attached directly to the reply. Otherwise it's pushed to copyparty and the +/// reply contains the download URL. Upload failures are reported gracefully +/// rather than propagated, matching the Python original. +async fn deliver( + ctx: &Context<'_>, + bytes: Vec, + filename: String, + force_upload: bool, + kind: DeliverKind, +) -> Result<()> { + if bytes.len() > LARGE_FILE_THRESHOLD || force_upload { + match ctx.data().copyparty.upload(&filename, bytes).await { + Ok(url) => { + let msg = match kind { + DeliverKind::Compress => lang::fmt_util_compressed(&url), + DeliverKind::Convert => lang::fmt_util_converted(&url), + }; + ctx.say(msg).await?; + } + Err(e) => { + tracing::warn!("copyparty upload failed: {e}"); + ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; + } + } + } else { + ctx.send( + poise::CreateReply::default() + .attachment(serenity::CreateAttachment::bytes(bytes, filename)), + ) + .await?; + } + Ok(()) +} + +/// Compress media to extreme low quality +#[poise::command(slash_command, aliases("kompresja"))] +pub async fn compress( + ctx: Context<'_>, + #[description = "File from Copyparty"] + #[autocomplete = "any_file"] + plik: Option, + #[description = "File from Discord"] zalacznik: Option, + #[description = "Force upload to Copyparty"] serwer: Option, +) -> Result<()> { + ctx.defer().await?; + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + + let (input_bytes, original_filename) = + match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? { + Some(v) => v, + None => return Ok(()), + }; + + let ft = file_type(&original_filename); + let stem = stem_of(&original_filename).to_string(); + let out_ext = compress_output_ext(ft, &original_filename); + let out_filename = format!("compressed_{stem}.{out_ext}"); + let input_path = temp_path_for(&original_filename); + let output_path = temp_path_for(&out_filename); + let cleanup_paths = [input_path.as_str(), output_path.as_str()]; + + if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { + tracing::warn!("compress: failed to write input: {e}"); + ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; + return Ok(()); + } + + let result = match build_compress_command(ft, &input_path, &output_path) { + Some(mut cmd) => cmd.output().await, + None => { + cleanup(&cleanup_paths).await; + ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; + return Ok(()); + } + }; + + match result { + Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await { + Ok(out_bytes) => { + cleanup(&cleanup_paths).await; + deliver( + &ctx, + out_bytes, + out_filename, + serwer.unwrap_or(false), + DeliverKind::Compress, + ) + .await?; + } + Err(e) => { + tracing::warn!("compress: output missing after encode: {e}"); + cleanup(&cleanup_paths).await; + ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; + } + }, + _ => { + cleanup(&cleanup_paths).await; + ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; + } + } + Ok(()) +} + +/// Convert media between formats +#[poise::command(slash_command, aliases("konwersja"))] +pub async fn convert( + ctx: Context<'_>, + #[description = "File from Copyparty"] + #[autocomplete = "any_file"] + plik: Option, + #[description = "File from Discord"] zalacznik: Option, + #[description = "Target format extension (e.g. mp3, wav, avi, png)"] format: String, + #[description = "Force upload to Copyparty"] serwer: Option, +) -> Result<()> { + ctx.defer().await?; + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + + let (input_bytes, original_filename) = + match resolve_input(&ctx, plik.as_deref(), zalacznik.as_ref()).await? { + Some(v) => v, + None => return Ok(()), + }; + + // Normalise the requested extension: lowercase, no leading dot, and reject + // anything containing path separators or extra dots — this keeps it a + // single clean extension component and rules out escaping TEMP_PATH. + let fmt = format.trim().trim_start_matches('.').to_ascii_lowercase(); + if fmt.is_empty() || fmt.contains('/') || fmt.contains('\\') || fmt.contains('.') { + ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; + return Ok(()); + } + + let ft = file_type(&original_filename); + let stem = stem_of(&original_filename).to_string(); + let out_filename = format!("{stem}.{fmt}"); + let input_path = temp_path_for(&original_filename); + let output_path = temp_path_for(&out_filename); + let cleanup_paths = [input_path.as_str(), output_path.as_str()]; + + if let Err(e) = tokio::fs::write(&input_path, &input_bytes).await { + tracing::warn!("convert: failed to write input: {e}"); + ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; + return Ok(()); + } + + let mut cmd = match ft { + FileType::Video | FileType::Audio => { + let mut c = tokio::process::Command::new("ffmpeg"); + c.arg("-y").arg("-i").arg(&input_path).arg(&output_path); + c + } + FileType::Image => { + let mut c = tokio::process::Command::new("magick"); + c.arg(&input_path).arg(&output_path); + c + } + FileType::Unknown => { + cleanup(&cleanup_paths).await; + ctx.say(lang::L_UTIL_UNSUPPORTED_TYPE).await?; + return Ok(()); + } + }; + + match cmd.output().await { + Ok(out) if out.status.success() => match tokio::fs::read(&output_path).await { + Ok(out_bytes) => { + cleanup(&cleanup_paths).await; + deliver( + &ctx, + out_bytes, + out_filename, + serwer.unwrap_or(false), + DeliverKind::Convert, + ) + .await?; + } + Err(e) => { + tracing::warn!("convert: output missing after encode: {e}"); + cleanup(&cleanup_paths).await; + ctx.say(lang::L_UTIL_CONVERT_FAILED).await?; + } + }, + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + cleanup(&cleanup_paths).await; + ctx.say(lang::fmt_util_convert_err(&stderr)).await?; + } + Err(e) => { + cleanup(&cleanup_paths).await; + ctx.say(lang::fmt_util_convert_err(&e.to_string())).await?; + } + } + Ok(()) +} + +/// Upload files from Discord to Copyparty +#[poise::command(slash_command)] +pub async fn upload( + ctx: Context<'_>, + #[description = "File to upload"] zalacznik: serenity::Attachment, +) -> Result<()> { + ctx.defer().await?; + + let data = ctx + .data() + .http_client + .get(&zalacznik.url) + .send() + .await? + .bytes() + .await?; + let file_url = ctx + .data() + .copyparty + .upload(&zalacznik.filename, data.to_vec()) + .await?; + ctx.say(lang::fmt_uploaded(&file_url)).await?; + Ok(()) +} + +/// Search Copyparty file server and return a direct URL +#[poise::command(slash_command)] +pub async fn search( + ctx: Context<'_>, + #[description = "Partial filename to search"] + #[autocomplete = "any_file"] + filename: String, +) -> Result<()> { + // Autocomplete cache: per-user (UserId, filename) → SearchHit. + if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &filename).await { + let name = html_decode(&percent_decode(hit.filename().unwrap_or(&filename))); + let path = hit.url_path().unwrap_or(""); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let full_url = format!("{}{}", ctx.data().config.copyparty_url, path); + ctx.say(lang::fmt_search_result(&name, &full_url)).await?; + return Ok(()); + } + + // Direct copyparty path from manual input (starts with /) + if filename.starts_with('/') { + let full_url = format!("{}{}", ctx.data().config.copyparty_url, filename); + ctx.say(lang::fmt_search_result(&filename, &full_url)) + .await?; + return Ok(()); + } + + let results = ctx.data().copyparty.search(&filename, 10, &[]).await?; + + if results.is_empty() { + ctx.say(lang::L_SEARCH_NO_RESULTS).await?; + return Ok(()); + } + + let hit = &results[0]; + let name = html_decode(&percent_decode(hit.filename().unwrap_or("unknown"))); + let path = hit.url_path().unwrap_or(""); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let full_url = format!("{}{}", ctx.data().config.copyparty_url, path); + + ctx.say(lang::fmt_search_result(&name, &full_url)).await?; + Ok(()) +} + +/// Download media from any yt-dlp-supported URL +#[poise::command(slash_command)] +pub async fn download( + ctx: Context<'_>, + #[description = "Media URL"] url: String, + #[description = "Force upload to Copyparty"] catbox: Option, + #[description = "Output format (mp4, mp3, wav, etc.)"] fformat: Option, + #[description = "Custom output filename"] filename_user: Option, +) -> Result<()> { + ctx.defer().await?; + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + run_download( + &ctx, + &url, + catbox.unwrap_or(false), + fformat.as_deref(), + filename_user.as_deref(), + ) + .await +} + +/// Right-click a message → upload its attachments to Copyparty +/// (mirrors Python's `@commands.message_command(name="upload")`). +/// +/// Display name is not `"upload"` because poise's dispatcher matches commands +/// by name alone without considering `CommandType`, so a context-menu named +/// `"upload"` would shadow — and be shadowed by — the slash `/upload` command +/// (see `poise::dispatch::find_matching_command`). `"Wypchnij załączniki"` +/// avoids the collision while keeping the Polish user-facing label. +#[poise::command(context_menu_command = "Wyślij załączniki")] +pub async fn message_upload(ctx: Context<'_>, message: serenity::Message) -> Result<()> { + if message.attachments.is_empty() { + ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; + return Ok(()); + } + ctx.defer().await?; + + let mut urls: Vec = Vec::new(); + let mut failed = 0usize; + for att in &message.attachments { + let resp = match ctx.data().http_client.get(&att.url).send().await { + Ok(r) if r.status().is_success() => r, + _ => { + failed += 1; + continue; + } + }; + let bytes = resp.bytes().await?.to_vec(); + match ctx.data().copyparty.upload(&att.filename, bytes).await { + Ok(url) => urls.push(url), + Err(e) => { + tracing::warn!("upload: copyparty failed for {}: {e}", att.filename); + failed += 1; + } + } + } + + if urls.is_empty() { + ctx.say(lang::L_UTIL_UPLOAD_FAILED).await?; + return Ok(()); + } + + let mut resp = lang::fmt_upload_summary(urls.len()); + for u in &urls { + resp.push_str(u); + resp.push('\n'); + } + if failed > 0 { + resp.push_str(&lang::fmt_upload_partial_failed(failed)); + } + ctx.say(resp).await?; + Ok(()) +} + +/// Right-click a message → compress all of its attachments +/// (mirrors Python's `@commands.message_command(name="kompresja")`). +#[poise::command(context_menu_command = "kompresja")] +pub async fn message_compression(ctx: Context<'_>, message: serenity::Message) -> Result<()> { + if message.attachments.is_empty() { + ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; + return Ok(()); + } + ctx.defer().await?; + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + + let total = message.attachments.len(); + let mut attachments_out: Vec = Vec::new(); + let mut made = 0usize; + + for att in &message.attachments { + let resp = match ctx.data().http_client.get(&att.url).send().await { + Ok(r) if r.status().is_success() => r, + _ => continue, + }; + let bytes = match resp.bytes().await { + Ok(b) => b.to_vec(), + Err(_) => continue, + }; + let ft = file_type(&att.filename); + if let Some((out_bytes, out_name)) = + compress_attachment_bytes(ft, &att.filename, &bytes).await + { + attachments_out.push(serenity::CreateAttachment::bytes(out_bytes, out_name)); + made += 1; + } + } + + if attachments_out.is_empty() { + ctx.say(lang::L_UTIL_COMPRESS_FAILED).await?; + return Ok(()); + } + + let mut reply = + poise::CreateReply::default().content(lang::fmt_message_compressed(made, total)); + for a in attachments_out { + reply = reply.attachment(a); + } + ctx.send(reply).await?; + Ok(()) +} + +/// Right-click a message → download every `https://` URL it contains +/// (mirrors Python's `@commands.message_command(name="download")`). +/// +/// Named `"Pobierz z linku"` rather than `"download"` to avoid colliding with +/// the slash `/download` command in poise's name-only dispatcher (see the note +/// on `message_upload`). +#[poise::command(context_menu_command = "Pobierz z linku")] +pub async fn message_download(ctx: Context<'_>, message: serenity::Message) -> Result<()> { + let urls = extract_urls(&message.content); + if urls.is_empty() { + // Faithful to the Python original, which reuses the attachments-missing + // string for the no-URL case. + ctx.say(lang::L_UTIL_NO_ATTACHMENTS).await?; + return Ok(()); + } + ctx.defer().await?; + tokio::fs::create_dir_all(crate::config::TEMP_PATH) + .await + .ok(); + + for url in urls { + if let Err(e) = run_download(&ctx, &url, false, Some("mp4"), None).await { + tracing::warn!("download runner error for {url}: {e}"); + } + } + Ok(()) +} + +/// Echo text to channel +#[poise::command(slash_command)] +pub async fn say(ctx: Context<'_>, #[description = "Message to send"] text: String) -> Result<()> { + ctx.say(&text).await?; + ctx.send( + poise::CreateReply::default() + .content("\u{200b}") + .ephemeral(true), + ) + .await?; + Ok(()) +} + +pub fn commands() -> Vec> { + vec![ + compress(), + convert(), + upload(), + search(), + download(), + say(), + message_upload(), + message_compression(), + message_download(), + ] +} diff --git a/src/commands/web.rs b/src/commands/web.rs new file mode 100644 index 0000000..d68f663 --- /dev/null +++ b/src/commands/web.rs @@ -0,0 +1,122 @@ +use poise::serenity_prelude as serenity; +use scraper::{Html, Selector}; + +use crate::Data; +use crate::error::{Error, Result}; +use crate::lang; + +type Context<'a> = poise::Context<'a, Data, Error>; + +/// Search the web via DuckDuckGo +#[poise::command(slash_command)] +pub async fn web(ctx: Context<'_>, #[description = "Search query"] query: String) -> Result<()> { + ctx.defer().await?; + + let url = format!( + "https://html.duckduckgo.com/html/?q={}", + urlencoding(&query) + ); + let html = ctx + .data() + .http_client + .get(&url) + .send() + .await? + .text() + .await?; + + let results = { + let document = Html::parse_document(&html); + + let result_selector = Selector::parse(".result").unwrap(); + let title_selector = Selector::parse(".result__title a").unwrap(); + let snippet_selector = Selector::parse(".result__snippet").unwrap(); + + let mut results = Vec::new(); + for result in document.select(&result_selector).take(5) { + let title = result + .select(&title_selector) + .next() + .map(|a| a.text().collect::()) + .unwrap_or_default(); + + let url = result + .select(&title_selector) + .next() + .and_then(|a| a.attr("href")) + .unwrap_or("") + .to_string(); + + let snippet = result + .select(&snippet_selector) + .next() + .map(|s| s.text().collect::()) + .unwrap_or_default(); + + let clean_url = if let Some(encoded) = url.split("uddg=").nth(1) { + let encoded = encoded.split('&').next().unwrap_or(encoded); + url_decode(encoded) + } else { + url.to_string() + }; + + results.push((title, clean_url, snippet)); + } + results + }; + + if results.is_empty() { + ctx.say(lang::L_WEB_NO_RESULTS).await?; + return Ok(()); + } + + let mut embed = serenity::CreateEmbed::default() + .title(lang::fmt_web_title(&query)) + .color(0x00aaff); + + for (i, (title, url, snippet)) in results.iter().enumerate() { + embed = embed.field( + format!("{}. {title}", i + 1), + format!("{url}\n{snippet}"), + false, + ); + } + + ctx.send(poise::CreateReply::default().embed(embed)).await?; + Ok(()) +} + +fn url_decode(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + match c { + '+' => result.push(' '), + '%' => { + let hi = chars.next().and_then(|c| c.to_digit(16)); + let lo = chars.next().and_then(|c| c.to_digit(16)); + if let (Some(hi), Some(lo)) = (hi, lo) { + result.push(char::from_u32((hi << 4) | lo).unwrap_or('�')); + } else { + result.push('%'); + } + } + _ => result.push(c), + } + } + result +} + +fn urlencoding(s: &str) -> String { + s.chars() + .map(|c| match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '~' => c.to_string(), + ' ' => "+".to_string(), + _ => format!("%{:02X}", c as u8), + }) + .collect() +} + +pub fn commands() -> Vec> { + vec![web()] +} -- cgit v1.2.3