Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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(&quoted.content, bot_id);
format_quote_context(&quoted.author.name, &quoted_content, &prompt)
}
None => prompt,
};

// No text and no attachments → skip
if prompt.is_empty() && msg.attachments.is_empty() {
return;
Expand Down Expand Up @@ -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<Message> {
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<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"<@&\d+>").unwrap()
});
Expand Down Expand Up @@ -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()));
}
}
Loading