PicoBot/src/tools/session_send.rs
oudecheng 694b3ce0e0 fix: send_session_message 增加详细 debug 日志和文件名归一化匹配
- filename_matches_target 关键日志从 trace 升级到 debug
- 增加 on-disk bytes hex dump 输出,便于定位编码差异
- UTF-8 解码成功但不匹配时继续尝试 GBK 解码
- 新增 normalize_filename() 去除空白/零宽字符后模糊比对
  解决 LLM 在中文文件名中多插空格的问题

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:44:47 +08:00

654 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::anyhow;
use async_trait::async_trait;
use base64::Engine;
#[cfg(unix)]
use encoding_rs::GBK;
use serde_json::json;
use crate::bus::MediaItem;
use super::traits::{Tool, ToolContext, ToolResult};
#[derive(Debug, Clone)]
pub struct SessionSendRequest {
pub text: Option<String>,
pub attachments: Vec<MediaItem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionSendOutcome {
pub published_messages: usize,
pub text_sent: bool,
pub attachment_count: usize,
}
#[async_trait]
pub trait SessionMessageSender: Send + Sync + 'static {
async fn send_to_current_session(
&self,
context: &ToolContext,
request: SessionSendRequest,
) -> anyhow::Result<SessionSendOutcome>;
}
pub struct NoopSessionMessageSender;
#[async_trait]
impl SessionMessageSender for NoopSessionMessageSender {
async fn send_to_current_session(
&self,
_context: &ToolContext,
_request: SessionSendRequest,
) -> anyhow::Result<SessionSendOutcome> {
Err(anyhow!(
"session send tool is not configured with an outbound sender"
))
}
}
pub struct SessionSendTool {
sender: Arc<dyn SessionMessageSender>,
}
impl SessionSendTool {
pub fn new(sender: Arc<dyn SessionMessageSender>) -> Self {
Self { sender }
}
}
#[async_trait]
impl Tool for SessionSendTool {
fn name(&self) -> &str {
"send_session_message"
}
fn description(&self) -> &str {
"Send a message to the current conversation through the normal channel reply path. You can send a text-only message, one or more local file attachments, or a text message followed by attachments. Use this when you need to proactively deliver content back to the current user."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Optional text to send to the current conversation. This tool can send a text-only message even when no attachments are provided."
},
"attachments": {
"type": "array",
"description": "Optional list of local file paths to send to the current conversation.",
"items": {
"type": "string",
"description": "Absolute or workspace-relative local file path"
}
}
},
"anyOf": [
{ "required": ["text"] },
{ "required": ["attachments"] }
]
})
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(error_result(
"send_session_message requires tool context for the current conversation",
))
}
async fn execute_with_context(
&self,
context: &ToolContext,
args: serde_json::Value,
) -> anyhow::Result<ToolResult> {
if let Err(err) = validate_context(context) {
return Ok(error_result(&err.to_string()));
}
let text = args
.get("text")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let attachments = match args.get("attachments") {
Some(value) => match parse_attachments(value) {
Ok(attachments) => attachments,
Err(err) => return Ok(error_result(&err.to_string())),
},
None => Vec::new(),
};
if text.is_none() && attachments.is_empty() {
return Ok(error_result(
"send_session_message requires non-empty text, attachments, or both",
));
}
let outcome = match self
.sender
.send_to_current_session(
context,
SessionSendRequest {
text,
attachments,
},
)
.await
{
Ok(outcome) => outcome,
Err(err) => return Ok(error_result(&err.to_string())),
};
Ok(ToolResult {
success: true,
output: format_success(outcome),
error: None,
})
}
}
fn validate_context(context: &ToolContext) -> anyhow::Result<()> {
if context.channel_name.as_deref().unwrap_or_default().is_empty() {
return Err(anyhow!(
"send_session_message requires channel_name in tool context"
));
}
if context.chat_id.as_deref().unwrap_or_default().is_empty() {
return Err(anyhow!(
"send_session_message requires chat_id in tool context"
));
}
Ok(())
}
/// 解析附件文件路径,支持非 UTF-8 编码的文件名回退。
///
/// 在 Linux 上,文件名可能是 GBK 等非 UTF-8 编码。当 LLM 通过 bash 工具
/// 的 `ls` 输出获取文件名时bash 工具会将非 UTF-8 输出解码为 UTF-8
/// LLM 使用的是 UTF-8 字符串,与磁盘上的实际字节不匹配。
///
/// 此函数的策略是:
/// 1. 先按 UTF-8 原样访问(最快路径)
/// 2. 如果失败,列出父目录,用多种编码解码磁盘上的文件名字节,
/// 与目标文件名比对,找到匹配项后返回实际磁盘路径
fn resolve_attachment_path(raw_path: &str) -> PathBuf {
let path = Path::new(raw_path);
tracing::debug!(
raw_path = %raw_path,
raw_path_bytes = ?raw_path.as_bytes(),
"resolve_attachment_path: attempting to resolve path"
);
// 1. 先按原样UTF-8尝试
if path.exists() {
tracing::debug!(
raw_path = %raw_path,
"resolve_attachment_path: path exists as-is (UTF-8)"
);
return path.to_path_buf();
}
tracing::debug!(
raw_path = %raw_path,
"resolve_attachment_path: path not found as UTF-8, trying directory scan"
);
// 2. 提取父目录和文件名,列出目录逐项比对
if let (Some(parent), Some(target_filename)) = (path.parent(), path.file_name()) {
let target_str = target_filename.to_string_lossy();
tracing::debug!(
parent = %parent.display(),
target_filename = %target_str,
target_filename_bytes = ?target_filename.as_encoded_bytes(),
"resolve_attachment_path: scanning parent directory"
);
match std::fs::read_dir(parent) {
Ok(entries) => {
let mut entry_count = 0;
for entry in entries.flatten() {
entry_count += 1;
let entry_name = entry.file_name();
let is_match = filename_matches_target(&entry_name, &target_str);
tracing::trace!(
entry_path = %entry.path().display(),
entry_name_lossy = %entry_name.to_string_lossy(),
entry_name_bytes = ?entry_name.as_encoded_bytes(),
is_match = is_match,
"resolve_attachment_path: checking entry"
);
if is_match {
tracing::debug!(
entry_path = %entry.path().display(),
entry_name_lossy = %entry_name.to_string_lossy(),
"resolve_attachment_path: MATCH FOUND via encoding fallback"
);
return entry.path();
}
}
tracing::debug!(
parent = %parent.display(),
entry_count = entry_count,
"resolve_attachment_path: directory scan complete, no match"
);
}
Err(e) => {
tracing::warn!(
parent = %parent.display(),
error = %e,
"resolve_attachment_path: failed to read directory"
);
}
}
} else {
tracing::debug!(
raw_path = %raw_path,
has_parent = path.parent().is_some(),
has_filename = path.file_name().is_some(),
"resolve_attachment_path: cannot extract parent or filename"
);
}
// 回退失败,返回原路径(让调用方报错)
tracing::warn!(
raw_path = %raw_path,
"resolve_attachment_path: all resolution attempts failed, returning original path"
);
path.to_path_buf()
}
/// 检查磁盘上的文件名OsStr是否与目标 UTF-8 文件名匹配。
///
/// 在 Unix 上,尝试用多种编码解码磁盘字节,与目标字符串比对。
#[cfg(unix)]
fn filename_matches_target(on_disk_name: &std::ffi::OsStr, target: &str) -> bool {
use std::os::unix::ffi::OsStrExt;
let bytes = on_disk_name.as_bytes();
tracing::debug!(
on_disk_bytes_hex = %format_bytes_hex(bytes),
target = %target,
target_bytes_hex = %format_bytes_hex(target.as_bytes()),
"filename_matches_target: comparing on-disk bytes with target"
);
// 直接 UTF-8 匹配
match std::str::from_utf8(bytes) {
Ok(decoded) => {
let matches = decoded == target;
tracing::debug!(
decoded_utf8 = %decoded,
decoded_len = decoded.len(),
target_len = target.len(),
matches = matches,
"filename_matches_target: UTF-8 decode result"
);
if matches {
return true;
}
// UTF-8 匹配失败,继续尝试其他编码 — 可能磁盘上是 GBK
tracing::debug!(
"filename_matches_target: UTF-8 matched but strings differ, trying GBK decode"
);
}
Err(e) => {
tracing::debug!(
utf8_error = %e,
"filename_matches_target: not valid UTF-8, trying GBK decode"
);
}
}
// 尝试 GBK 解码
let (gbk_decoded, _, had_errors) = GBK.decode(bytes);
if !had_errors {
let matches = gbk_decoded == target;
tracing::debug!(
gbk_decoded = %gbk_decoded,
gbk_decoded_len = gbk_decoded.len(),
target = %target,
target_len = target.len(),
matches = matches,
"filename_matches_target: GBK decode result"
);
if matches {
return true;
}
// GBK 解码成功但不匹配,尝试归一化后比对
let normalized_disk = normalize_filename(&gbk_decoded);
let normalized_target = normalize_filename(target);
if normalized_disk == normalized_target {
tracing::debug!(
normalized_disk = %normalized_disk,
normalized_target = %normalized_target,
"filename_matches_target: matched after normalization"
);
return true;
}
} else {
tracing::debug!(
gbk_decoded_lossy = %gbk_decoded,
had_errors = had_errors,
"filename_matches_target: GBK decode had errors"
);
}
// 回退lossy 转换比对
let lossy = String::from_utf8_lossy(bytes);
let matches = lossy == target;
tracing::debug!(
lossy = %lossy,
target = %target,
matches = matches,
"filename_matches_target: lossy fallback result"
);
if matches {
return true;
}
// 最后尝试lossy 归一化比对
let normalized_lossy = normalize_filename(&lossy);
let normalized_target = normalize_filename(target);
if normalized_lossy == normalized_target {
tracing::debug!(
normalized_lossy = %normalized_lossy,
normalized_target = %normalized_target,
"filename_matches_target: matched after lossy normalization"
);
return true;
}
false
}
/// 对文件名做归一化处理:去除不可见字符和空白字符差异,便于模糊匹配。
///
/// LLM 有时会在中文文件名中插入空格(如 "139 邮箱" vs "139邮箱"
/// 此函数去掉所有空白字符和零宽字符,只比对有意义的文字部分。
#[cfg(unix)]
fn normalize_filename(s: &str) -> String {
s.chars()
.filter(|c| match *c {
// 过滤 ASCII 空白
' ' | '\t' | '\n' | '\r' => false,
// 过滤零宽字符
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{200E}' | '\u{200F}' => false,
// 过滤 Unicode 空白字符
'\u{00A0}' | '\u{3000}' => false,
// 保留其他所有字符
_ => true,
})
.collect()
}
/// 将字节切片格式化为十六进制字符串,用于调试日志。
#[cfg(unix)]
fn format_bytes_hex(bytes: &[u8]) -> String {
bytes
.iter()
.map(|b| format!("{:02x}", b))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(not(unix))]
fn filename_matches_target(on_disk_name: &std::ffi::OsStr, target: &str) -> bool {
let matches = on_disk_name.to_string_lossy() == target;
tracing::trace!(
on_disk_lossy = %on_disk_name.to_string_lossy(),
target = %target,
matches = matches,
"filename_matches_target (non-unix): comparing"
);
matches
}
fn parse_attachments(value: &serde_json::Value) -> anyhow::Result<Vec<MediaItem>> {
// 支持两种格式:实际数组 或 字符串化的 JSON 数组
let paths = if let Some(arr) = value.as_array() {
arr
.iter()
.filter_map(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
} else if let Some(s) = value.as_str() {
// 尝试解析字符串化的 JSON 数组
serde_json::from_str::<Vec<serde_json::Value>>(s)
.ok()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default()
} else {
vec![]
};
if paths.is_empty() {
return Err(anyhow!("attachments must be an array of local file paths"));
}
let mut attachments = Vec::with_capacity(paths.len());
for raw_path in paths {
// 解析路径(含编码回退),确保能正确访问非 UTF-8 文件名的文件
let resolved_path = resolve_attachment_path(&raw_path);
let resolved_path_str = resolved_path.to_string_lossy().to_string();
let metadata = std::fs::metadata(&resolved_path)
.map_err(|err| anyhow!("failed to access attachment '{}': {}", raw_path, err))?;
if !metadata.is_file() {
return Err(anyhow!("attachment path is not a file: {}", raw_path));
}
if metadata.len() == 0 {
return Err(anyhow!("attachment file is empty: {}", raw_path));
}
let content_base64 = (metadata.len() <= 50 * 1024 * 1024)
.then(|| {
let mut file = std::fs::File::open(&resolved_path)?;
let mut buf = Vec::with_capacity(metadata.len() as usize);
file.read_to_end(&mut buf)?;
Ok::<_, anyhow::Error>(base64::engine::general_purpose::STANDARD.encode(&buf))
})
.transpose()?;
let file_name = Path::new(&resolved_path)
.file_name()
.map(|n| n.to_string_lossy().to_string());
let media_type = infer_media_type(&resolved_path_str);
let mut item = MediaItem::new(resolved_path_str, media_type);
item.mime_type = mime_guess::from_path(&resolved_path)
.first_raw()
.map(ToOwned::to_owned);
item.content_base64 = content_base64;
item.file_name = file_name;
attachments.push(item);
}
Ok(attachments)
}
fn infer_media_type(path: &str) -> &'static str {
let mime = mime_guess::from_path(path).first_or_octet_stream();
if mime.essence_str().starts_with("image/") {
return "image";
}
match Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.as_deref()
{
Some("mp3") | Some("wav") | Some("ogg") | Some("m4a") | Some("opus") => "audio",
Some("mp4") | Some("mov") | Some("avi") | Some("mkv") => "video",
_ => "file",
}
}
fn format_success(outcome: SessionSendOutcome) -> String {
match (outcome.text_sent, outcome.attachment_count) {
(true, 0) => "Sent 1 text message to the current conversation.".to_string(),
(false, count) => format!("Sent {} attachment(s) to the current conversation.", count),
(true, count) => format!(
"Sent 1 text message and {} attachment(s) to the current conversation.",
count
),
}
}
fn error_result(message: &str) -> ToolResult {
ToolResult {
success: false,
output: String::new(),
error: Some(message.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
const TEST_CHANNEL: &str = "test-channel";
struct MockSender {
outcome: SessionSendOutcome,
}
#[async_trait]
impl SessionMessageSender for MockSender {
async fn send_to_current_session(
&self,
_context: &ToolContext,
_request: SessionSendRequest,
) -> anyhow::Result<SessionSendOutcome> {
Ok(self.outcome)
}
}
fn context() -> ToolContext {
ToolContext {
channel_name: Some(TEST_CHANNEL.to_string()),
chat_id: Some("chat-1".to_string()),
..ToolContext::default()
}
}
#[tokio::test]
async fn send_session_message_supports_text_only() {
let tool = SessionSendTool::new(Arc::new(MockSender {
outcome: SessionSendOutcome {
published_messages: 1,
text_sent: true,
attachment_count: 0,
},
}));
let result = tool
.execute_with_context(&context(), json!({ "text": "hello" }))
.await
.unwrap();
assert!(result.success);
assert_eq!(result.output, "Sent 1 text message to the current conversation.");
}
#[tokio::test]
async fn send_session_message_rejects_empty_request() {
let tool = SessionSendTool::new(Arc::new(MockSender {
outcome: SessionSendOutcome {
published_messages: 0,
text_sent: false,
attachment_count: 0,
},
}));
let result = tool
.execute_with_context(&context(), json!({}))
.await
.unwrap();
assert!(!result.success);
assert_eq!(
result.error.as_deref(),
Some("send_session_message requires non-empty text, attachments, or both")
);
}
#[test]
fn parse_attachments_infers_image_media_type() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), b"demo").unwrap();
let image_path = file.path().with_extension("png");
std::fs::rename(file.path(), &image_path).unwrap();
let attachments = parse_attachments(&json!([image_path.to_string_lossy().to_string()]))
.unwrap();
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].media_type, "image");
}
#[test]
fn parse_attachments_handles_stringified_json_array() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), b"demo").unwrap();
let txt_path = file.path().with_extension("txt");
std::fs::rename(file.path(), &txt_path).unwrap();
// Test with stringified JSON array (like LLM might send)
let path_str = txt_path.to_string_lossy().to_string().replace("\\", "\\\\");
let json_string = format!("[\"{}\"]", path_str);
let attachments = parse_attachments(&json!(json_string)).unwrap();
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].media_type, "file");
}
#[test]
#[cfg(unix)]
fn parse_attachments_resolves_gbk_encoded_filename() {
use encoding_rs::GBK;
use std::os::unix::ffi::OsStringExt;
// UTF-8 中文文件名
let utf8_filename = "测试文件.txt";
// 编码为 GBK 字节
let (gbk_bytes, _, had_errors) = GBK.encode(utf8_filename);
assert!(!had_errors);
// 在临时目录中创建 GBK 编码文件名的文件
let tmp_dir = tempfile::tempdir().unwrap();
let gbk_osstring = std::ffi::OsString::from_vec(gbk_bytes.into_owned());
let gbk_path = tmp_dir.path().join(gbk_osstring);
std::fs::write(&gbk_path, b"test content").unwrap();
// 用 UTF-8 路径调用 parse_attachments
let utf8_path = tmp_dir.path().join(utf8_filename);
let utf8_path_str = utf8_path.to_string_lossy().to_string();
let attachments = parse_attachments(&json!([utf8_path_str])).unwrap();
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].media_type, "file");
// 验证文件名能正确提取(用 lossy 方式,因为是 GBK 编码)
assert!(attachments[0].file_name.is_some());
}
}