From 764a811012c9fae49f89891b07d88599e21d4d06 Mon Sep 17 00:00:00 2001 From: masami-agent Date: Fri, 10 Apr 2026 04:52:17 +0000 Subject: [PATCH 1/3] fix: prevent duplicate messages for long replies (>1900 chars) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During streaming, truncate content at 1900 chars with '…' instead of splitting into multiple messages via channel.say(). The final edit after streaming completes handles proper multi-chunk delivery. Previously both the streaming task and final edit independently called channel.say() for overflow chunks, causing duplicates. Closes #81 --- src/discord.rs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index 5b4bb8b0..74c39a6a 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -232,25 +232,18 @@ async fn stream_prompt( let mut buf_rx = buf_rx.clone(); tokio::spawn(async move { let mut last_content = String::new(); - let mut current_edit_msg = msg_id; + let current_edit_msg = msg_id; loop { tokio::time::sleep(std::time::Duration::from_millis(1500)).await; if buf_rx.has_changed().unwrap_or(false) { let content = buf_rx.borrow_and_update().clone(); if content != last_content { - if content.len() > 1900 { - let chunks = format::split_message(&content, 1900); - if let Some(first) = chunks.first() { - let _ = edit(&ctx, channel, current_edit_msg, first).await; - } - for chunk in chunks.iter().skip(1) { - if let Ok(new_msg) = channel.say(&ctx.http, chunk).await { - current_edit_msg = new_msg.id; - } - } + let display = if content.len() > 1900 { + format!("{}…", &content[..1900]) } else { - let _ = edit(&ctx, channel, current_edit_msg, &content).await; - } + content.clone() + }; + let _ = edit(&ctx, channel, current_edit_msg, &display).await; last_content = content; } } From aa8647c2c78903299a3d0ed5e1e975490a3a2db6 Mon Sep 17 00:00:00 2001 From: masami-agent Date: Fri, 10 Apr 2026 04:53:39 +0000 Subject: [PATCH 2/3] fix: use char boundary for UTF-8 safe truncation content[..1900] can panic if 1900 falls inside a multi-byte UTF-8 character (e.g. CJK, emoji). Walk back to the nearest char boundary. --- src/discord.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/discord.rs b/src/discord.rs index 74c39a6a..60588752 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -239,7 +239,11 @@ async fn stream_prompt( let content = buf_rx.borrow_and_update().clone(); if content != last_content { let display = if content.len() > 1900 { - format!("{}…", &content[..1900]) + let mut end = 1900; + while !content.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &content[..end]) } else { content.clone() }; From 5945bd29f69bb0e4a554a4a48cc37b347a3d4c67 Mon Sep 17 00:00:00 2001 From: masami-agent Date: Fri, 10 Apr 2026 04:56:47 +0000 Subject: [PATCH 3/3] fix: use char count instead of byte length for all message splitting Discord's 2000-char limit is Unicode characters, not bytes. CJK chars are 3 bytes each, so byte-based limits truncate far too early and as_bytes().chunks() corrupts multi-byte characters. - discord.rs: streaming truncation uses chars().count() / chars().take() - format.rs: split_message uses char count for all boundary checks, hard-split iterates chars instead of byte chunks --- src/discord.rs | 9 +++------ src/format.rs | 29 +++++++++++++++++++++-------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index 60588752..ef16de65 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -238,12 +238,9 @@ async fn stream_prompt( if buf_rx.has_changed().unwrap_or(false) { let content = buf_rx.borrow_and_update().clone(); if content != last_content { - let display = if content.len() > 1900 { - let mut end = 1900; - while !content.is_char_boundary(end) { - end -= 1; - } - format!("{}…", &content[..end]) + let display = if content.chars().count() > 1900 { + let truncated: String = content.chars().take(1900).collect(); + format!("{truncated}…") } else { content.clone() }; diff --git a/src/format.rs b/src/format.rs index a0026ebb..63081e6e 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,31 +1,44 @@ -/// Split text into chunks at line boundaries, each <= limit chars. +/// Split text into chunks at line boundaries, each <= limit **characters**. pub fn split_message(text: &str, limit: usize) -> Vec { - if text.len() <= limit { + if text.chars().count() <= limit { return vec![text.to_string()]; } let mut chunks = Vec::new(); let mut current = String::new(); + let mut current_chars = 0usize; for line in text.split('\n') { + let line_chars = line.chars().count(); // +1 for the newline - if !current.is_empty() && current.len() + line.len() + 1 > limit { + if !current.is_empty() && current_chars + line_chars + 1 > limit { chunks.push(current); current = String::new(); + current_chars = 0; } if !current.is_empty() { current.push('\n'); + current_chars += 1; } - // If a single line exceeds limit, hard-split it - if line.len() > limit { - for chunk in line.as_bytes().chunks(limit) { - if !current.is_empty() { + // If a single line exceeds limit, hard-split it at char boundaries + if line_chars > limit { + let mut chars = line.chars(); + loop { + let chunk: String = chars.by_ref().take(limit - current_chars).collect(); + if chunk.is_empty() { + break; + } + current.push_str(&chunk); + current_chars += chunk.chars().count(); + if current_chars >= limit { chunks.push(current); + current = String::new(); + current_chars = 0; } - current = String::from_utf8_lossy(chunk).to_string(); } } else { current.push_str(line); + current_chars += line_chars; } } if !current.is_empty() {