PicoBot/src/channels/wechat.rs
oudecheng 29060315a3 feat(observability): 端到端可观测性整改,修复 trace_id 断链与指标配对
- 传播 trace_id:BusToolCallEmitter/SubAgentEmitter/processor 全链路设置

- AgentEnd 配对:补发 5 个 cancel/defensive 路径,闭合 AgentStart 指标

- LLM 计时修正:attempt_start 移入 retry 循环,排除退避等待时间

- /metrics auth:非 loopback 部署时纳入 Bearer token 校验

- recorder 复用:OnceLock 缓存 PrometheusHandle,热重启后不再返回 503

- 结构化日志:新增 tracing_ctx + JSON 日志格式支持
2026-08-12 08:26:42 +08:00

449 lines
15 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::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::UNIX_EPOCH;
use async_trait::async_trait;
use futures_util::FutureExt;
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use wechatbot::{BotOptions, SendContent, WeChatBot};
use crate::bus::message::OutboundEventKind;
use crate::bus::{InboundMessage, MediaItem, MessageBus, OutboundMessage};
use crate::channels::base::{Channel, ChannelError};
use crate::config::{LLMProviderConfig, WechatChannelConfig};
#[derive(Clone)]
pub struct WechatChannel {
name: String,
config: WechatChannelConfig,
bot: Arc<WeChatBot>,
running: Arc<AtomicBool>,
task: Arc<RwLock<Option<JoinHandle<()>>>>,
}
impl WechatChannel {
pub fn new(
name: String,
config: WechatChannelConfig,
_provider_config: LLMProviderConfig,
) -> Result<Self, ChannelError> {
let channel_name = name.clone();
let bot = WeChatBot::new(BotOptions {
base_url: Some(config.base_url.clone()),
cred_path: Some(config.cred_path.clone()),
on_qr_url: Some(Box::new(move |url| {
tracing::info!(channel = %channel_name, qr_url = %url, "WeChat QR code ready");
})),
on_error: Some(Box::new(move |error| {
tracing::error!(error = %error, "WeChat SDK error");
})),
});
Ok(Self {
name,
config,
bot: Arc::new(bot),
running: Arc::new(AtomicBool::new(false)),
task: Arc::new(RwLock::new(None)),
})
}
fn sender_allowed(&self, sender_id: &str) -> bool {
self.config
.allow_from
.iter()
.any(|pattern| pattern == "*" || pattern == sender_id)
}
async fn media_to_send_content(
media: &MediaItem,
caption: Option<String>,
) -> Result<SendContent, ChannelError> {
// 媒体文件读取是阻塞 IO放到 blocking 线程池避免阻塞 async worker。
let path = media.path.clone();
let data = tokio::task::spawn_blocking(move || std::fs::read(&path))
.await
.map_err(|e| {
ChannelError::SendError(format!("WeChat media read task failed: {}", e))
})?
.map_err(|error| {
ChannelError::SendError(format!(
"WeChat media read failed for '{}': {}",
media.path, error
))
})?;
if data.is_empty() {
return Err(ChannelError::SendError(format!(
"WeChat media file is empty: {}",
media.path
)));
}
let file_name = Path::new(&media.path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("attachment.bin")
.to_string();
match media.media_type.as_str() {
"image" => Ok(SendContent::Image { data, caption }),
"video" => Ok(SendContent::Video { data, caption }),
_ => Ok(SendContent::File {
data,
file_name,
caption,
}),
}
}
fn default_media_dir() -> PathBuf {
let home = crate::platform::picobot_home_dir();
home.join(".picobot").join("media").join("wechat")
}
fn build_download_filename(
media_type: &str,
file_name: Option<&str>,
format: Option<&str>,
) -> String {
if let Some(file_name) = file_name {
let sanitized: String = file_name
.chars()
.map(|ch| match ch {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => ch,
})
.collect();
if !sanitized.trim().is_empty() {
return format!("{}_{}", uuid::Uuid::new_v4(), sanitized);
}
}
let ext = match (media_type, format) {
("image", _) => "jpg",
("video", _) => "mp4",
("voice", Some(fmt)) if !fmt.trim().is_empty() => fmt,
("voice", _) => "bin",
_ => "bin",
};
format!("{}_{}.{}", media_type, uuid::Uuid::new_v4(), ext)
}
async fn download_inbound_media(
bot: Arc<WeChatBot>,
msg: wechatbot::IncomingMessage,
) -> Result<Vec<MediaItem>, ChannelError> {
let Some(downloaded) = bot.download(&msg).await.map_err(|error| {
ChannelError::Other(format!("WeChat media download failed: {}", error))
})?
else {
return Ok(Vec::new());
};
let media_dir = Self::default_media_dir();
tokio::fs::create_dir_all(&media_dir)
.await
.map_err(|error| {
ChannelError::Other(format!("Failed to create WeChat media dir: {}", error))
})?;
let filename = Self::build_download_filename(
&downloaded.media_type,
downloaded.file_name.as_deref(),
downloaded.format.as_deref(),
);
let file_path = media_dir.join(&filename);
tokio::fs::write(&file_path, downloaded.data)
.await
.map_err(|error| {
ChannelError::Other(format!("Failed to write WeChat media file: {}", error))
})?;
tracing::info!(filename = %filename, media_type = %downloaded.media_type, "Downloaded WeChat media");
let mut media_item = MediaItem::new(
file_path.to_string_lossy().to_string(),
downloaded.media_type,
);
media_item.mime_type = mime_guess::from_path(&file_path)
.first_raw()
.map(ToOwned::to_owned);
Ok(vec![media_item])
}
async fn send_typing_indicator(bot: Arc<WeChatBot>, chat_id: &str) {
if let Err(error) = bot.send_typing(chat_id).await {
tracing::debug!(chat_id = %chat_id, error = %error, "Failed to send WeChat typing indicator");
}
}
}
#[async_trait]
impl Channel for WechatChannel {
fn name(&self) -> &str {
&self.name
}
fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
async fn start(&self, bus: Arc<MessageBus>) -> Result<(), ChannelError> {
if self.running.swap(true, Ordering::SeqCst) {
return Ok(());
}
let channel_name = self.name.clone();
let allow_from = self.config.allow_from.clone();
let bus_for_handler = bus.clone();
let bot_for_handler = self.bot.clone();
self.bot
.on_message(Box::new(move |msg| {
let sender_id = msg.user_id.clone();
let allowed = allow_from
.iter()
.any(|pattern| pattern == "*" || pattern == &sender_id);
if !allowed {
tracing::warn!(channel = %channel_name, sender = %sender_id, "Access denied");
return;
}
let msg = msg.clone();
let timestamp = msg
.timestamp
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let bus = bus_for_handler.clone();
let bot = bot_for_handler.clone();
let channel_name_for_publish = channel_name.clone();
tokio::spawn(async move {
Self::send_typing_indicator(bot.clone(), &sender_id).await;
let media = match Self::download_inbound_media(bot, msg.clone()).await {
Ok(media) => media,
Err(error) => {
tracing::error!(error = %error, "Failed to download WeChat inbound media");
Vec::new()
}
};
let mut metadata = HashMap::new();
metadata.insert("context_token".to_string(), msg.context_token().to_string());
let inbound = InboundMessage {
channel: channel_name_for_publish,
sender_id: sender_id.clone(),
chat_id: sender_id,
content: msg.text.clone(),
timestamp,
media,
metadata,
forwarded_metadata: HashMap::new(),
trace_id: crate::observability::tracing_ctx::new_trace_id(),
};
if let Err(error) = bus.publish_inbound(inbound).await {
tracing::error!(error = %error, "Failed to publish WeChat inbound message");
}
});
}))
.await;
let bot = self.bot.clone();
let channel_name = self.name.clone();
let force_login = self.config.force_login;
let running = self.running.clone();
let handle = tokio::spawn(async move {
// Use catch_unwind to prevent a panic in the WeChat SDK (login or
// long-poll loop) from crashing the entire process. Any panic is
// logged and the channel is cleanly marked as stopped.
// AssertUnwindSafe is needed because WeChatBot contains internal
// locks (RwLock) that are not RefUnwindSafe.
let result = std::panic::AssertUnwindSafe(async {
match bot.login(force_login).await {
Ok(creds) => {
tracing::info!(
channel = %channel_name,
account_id = %creds.account_id,
user_id = %creds.user_id,
"WeChat login succeeded"
);
}
Err(error) => {
tracing::error!(channel = %channel_name, error = %error, "WeChat login failed");
return;
}
}
if let Err(error) = bot.run().await {
tracing::error!(channel = %channel_name, error = %error, "WeChat channel stopped with error");
}
})
.catch_unwind()
.await;
if let Err(_panic) = result {
tracing::error!(
channel = %channel_name,
"WeChat bot task panicked — marking channel as stopped"
);
}
running.store(false, Ordering::SeqCst);
});
*self.task.write().await = Some(handle);
tracing::info!(channel = %self.name, "WeChat channel started");
Ok(())
}
async fn stop(&self) -> Result<(), ChannelError> {
self.running.store(false, Ordering::SeqCst);
self.bot.stop().await;
if let Some(handle) = self.task.write().await.take() {
handle.abort();
}
tracing::info!(channel = %self.name, "WeChat channel stopped");
Ok(())
}
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
// WeChat iLink Bot has a ~10-message burst limit per context_token.
// Filter non-essential message types to conserve budget:
// - ToolCall: internal tool invocation details, not useful to WeChat users
// - ToolResult / ToolPending: raw tool output, not user-facing
// - Subagent events: internal agent orchestration
if matches!(
msg.event_kind,
OutboundEventKind::ToolResult
| OutboundEventKind::ToolPending
| OutboundEventKind::ToolCall
| OutboundEventKind::StreamDelta
| OutboundEventKind::StreamEnd
| OutboundEventKind::ExecutionCompleted
) || msg
.metadata
.get("is_subagent_event")
.map(|v| v == "true")
.unwrap_or(false)
{
return Ok(());
}
let text = msg.content.trim().to_string();
let mut text_sent = false;
if !text.is_empty() {
self.bot.send(&msg.chat_id, &text).await.map_err(|error| {
ChannelError::SendError(format!("WeChat text send failed: {}", error))
})?;
tracing::info!(
channel = %self.name,
chat_id = %msg.chat_id,
content_len = text.len(),
"WeChat text message sent"
);
text_sent = true;
}
for (index, media) in msg.media.iter().enumerate() {
let caption = if !text.is_empty() && !text_sent && index == 0 {
Some(text.clone())
} else {
None
};
let content = Self::media_to_send_content(media, caption).await?;
self.bot
.send_media(&msg.chat_id, content)
.await
.map_err(|error| {
ChannelError::SendError(format!("WeChat media send failed: {}", error))
})?;
tracing::info!(
channel = %self.name,
chat_id = %msg.chat_id,
media_type = %media.media_type,
media_path = %media.path,
"WeChat media message sent"
);
}
if text.is_empty() && msg.media.is_empty() {
return Ok(());
}
Ok(())
}
fn is_allowed(&self, sender_id: &str) -> bool {
self.sender_allowed(sender_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn build_download_filename_preserves_file_name() {
let filename = WechatChannel::build_download_filename("file", Some("README.md"), None);
assert!(filename.ends_with("_README.md"));
}
#[test]
fn build_download_filename_adds_voice_extension_when_missing_name() {
let filename = WechatChannel::build_download_filename("voice", None, Some("silk"));
assert!(filename.starts_with("voice_"));
assert!(filename.ends_with(".silk"));
}
#[tokio::test]
async fn media_to_send_content_maps_image() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), b"demo-image").unwrap();
let image_path = file.path().with_extension("png");
std::fs::rename(file.path(), &image_path).unwrap();
let media = MediaItem::new(image_path.to_string_lossy().to_string(), "image");
let content = WechatChannel::media_to_send_content(&media, None).await.unwrap();
assert!(matches!(content, SendContent::Image { .. }));
}
#[tokio::test]
async fn media_to_send_content_maps_generic_file() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), b"hello").unwrap();
let doc_path = file.path().with_extension("md");
std::fs::rename(file.path(), &doc_path).unwrap();
let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file");
let content =
WechatChannel::media_to_send_content(&media, Some("note".to_string())).await.unwrap();
match content {
SendContent::File {
file_name, caption, ..
} => {
assert_eq!(file_name, doc_path.file_name().unwrap().to_string_lossy());
assert_eq!(caption.as_deref(), Some("note"));
}
_ => panic!("expected file send content"),
}
}
}