diff --git a/src/discord.rs b/src/discord.rs index 7b4708ad..6db7adb7 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -15,6 +15,7 @@ use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, Reacti use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, MessageId, UserId}; use serenity::prelude::*; +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, OnceLock}; use tracing::{debug, error, info}; @@ -510,6 +511,15 @@ impl EventHandler for Handler { let prompt = resolve_mentions(&msg.content, bot_id); + // Prepend quoted/referenced message content when user replies to a message (#339) + let prompt = match resolve_referenced_message(&msg, &ctx.http).await { + Some(quoted) => { + let quoted_content = resolve_mentions("ed.content, bot_id); + format_quote_context("ed.author.name, "ed_content, &prompt) + } + None => prompt, + }; + // No text and no attachments → skip if prompt.is_empty() && msg.attachments.is_empty() { return; @@ -1061,6 +1071,50 @@ fn is_thread_already_exists_error(err: &anyhow::Error) -> bool { msg.contains("160004") || msg.contains("already been created") } +/// Resolve the referenced (quoted) message from a Discord reply. +/// Prefers the gateway-provided `referenced_message`; falls back to an HTTP API call +/// using `message_reference` if the gateway didn't include the full message. +async fn resolve_referenced_message(msg: &Message, http: &Http) -> Option { + if let Some(ref referenced) = msg.referenced_message { + return Some(*referenced.clone()); + } + let mr = msg.message_reference.as_ref()?; + let message_id = mr.message_id?; + let channel_id = mr.channel_id; + match channel_id.message(http, message_id).await { + Ok(fetched) => Some(fetched), + Err(e) => { + tracing::warn!(channel_id = %channel_id, message_id = %message_id, error = %e, "failed to fetch referenced message"); + None + } + } +} + +/// Maximum length (in bytes) for quoted content before truncation. +const MAX_QUOTE_LENGTH: usize = 1500; + +/// Format a quoted message block to prepend to the user's prompt. +fn format_quote_context(author_name: &str, quoted_content: &str, prompt: &str) -> String { + if quoted_content.is_empty() { + return prompt.to_string(); + } + let content = truncate_utf8(quoted_content, MAX_QUOTE_LENGTH); + format!("[Quoted message from @{author_name}]:\n{content}\n\n{prompt}") +} + +/// Truncate a string to at most `max_bytes` bytes on a valid UTF-8 char boundary. +fn truncate_utf8(s: &str, max_bytes: usize) -> Cow<'_, str> { + if s.len() <= max_bytes { + return Cow::Borrowed(s); + } + // Find the last char boundary at or before max_bytes + let mut end = max_bytes; + while !s.is_char_boundary(end) { + end -= 1; + } + Cow::Owned(format!("{}… [truncated]", &s[..end])) +} + static ROLE_MENTION_RE: LazyLock = LazyLock::new(|| { regex::Regex::new(r"<@&\d+>").unwrap() }); @@ -1768,4 +1822,66 @@ mod tests { fn normal_channel_creates_thread() { assert!(!should_skip_thread_creation(false, false)); } + + // --- format_quote_context tests (#339) --- + + /// Quoted message is prepended with author attribution. + #[test] + fn format_quote_prepends_context() { + let result = format_quote_context("Alice", "hello world", "summarize this"); + assert_eq!( + result, + "[Quoted message from @Alice]:\nhello world\n\nsummarize this" + ); + } + + /// Empty quoted content returns the prompt unchanged. + #[test] + fn format_quote_empty_content_passthrough() { + let result = format_quote_context("Alice", "", "summarize this"); + assert_eq!(result, "summarize this"); + } + + /// Empty prompt with quoted content still includes the quote block. + #[test] + fn format_quote_empty_prompt() { + let result = format_quote_context("Bob", "some context", ""); + assert_eq!(result, "[Quoted message from @Bob]:\nsome context\n\n"); + } + + /// Multi-line quoted content is preserved as-is. + #[test] + fn format_quote_multiline() { + let result = format_quote_context("Bot", "line1\nline2\nline3", "explain"); + assert_eq!( + result, + "[Quoted message from @Bot]:\nline1\nline2\nline3\n\nexplain" + ); + } + + /// Long quoted content is truncated at MAX_QUOTE_LENGTH. + #[test] + fn format_quote_truncates_long_content() { + let long = "a".repeat(2000); + let result = format_quote_context("Alice", &long, "summarize"); + assert!(result.contains("… [truncated]")); + // The quoted portion (between header and prompt) should be ≤ MAX_QUOTE_LENGTH + marker + let quoted_part = result + .strip_prefix("[Quoted message from @Alice]:\n") + .unwrap() + .strip_suffix("\n\nsummarize") + .unwrap(); + assert!(quoted_part.len() < 2000); + } + + /// Truncation respects UTF-8 char boundaries. + #[test] + fn format_quote_truncates_utf8_safe() { + // '你' is 3 bytes; fill just over the limit + let cjk = "你".repeat(600); // 1800 bytes + let result = format_quote_context("Bob", &cjk, "ok"); + assert!(result.contains("… [truncated]")); + // Must be valid UTF-8 (would panic on construction if not) + assert!(result.is_char_boundary(result.len())); + } }