//! 飞书适配器(P2):长连接事件 + 归一化消息 + 卡片审批 //! //! 依赖 larksuite-oapi-sdk-rs features=["channel"](自动连带 ws+messaging)。 //! 长连接:飞书←→网关出站直连,不经 u-relay(u-relay 仅穿 NAT 段到桌面端)。 //! //! 验证记录(2026-10-04,SDK 0.3.12 源码核对): //! - 卡片回调:ChannelBuilder::on_card_action(NormalizedCardAction) //! 内部走 dispatcher.on_card_action_trigger(channel/handler.rs:112), //! 即新版 card.action.trigger 事件通道,长连接可收(遗留验证项 ✅)。 //! - 发卡片:SendInput.card(String) → INTERACTIVE 类型(messaging.rs:752)。 //! - 流式更新:channel_messaging().send() 拿 message_id 后 edit_text() 覆写全文。 use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; use larksuite_oapi_sdk_rs::card::v1::{ Action, ActionComponent, Button, ButtonType, Card, CardDocument, Div, Element, Header, Markdown, }; use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy}; use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError, RequestOption}; use tokio::sync::mpsc::Sender; use crate::adapters::{InboundEvent, OutboundAction, PlatformAdapter}; use crate::config::FeishuConfig; /// 飞书适配器:channel 归一化消息 + 卡片审批,单飞书应用单实例 #[derive(Clone)] pub struct FeishuAdapter { client: LarkClient, /// 流式会话注册表:chat_id → 已发出的流式消息 message_id(用于 edit_text 覆写) streams: Arc>>, } impl FeishuAdapter { pub fn new(cfg: &FeishuConfig) -> anyhow::Result { let client = LarkClient::builder(&cfg.app_id, &cfg.app_secret).build()?; Ok(Self { client, streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())), }) } /// 构造失败时的离线哨兵:client 用占位凭证(不启动 run,dispatch 记日志即弃) pub fn offline() -> Self { let client = LarkClient::builder("offline", "offline").build() .expect("占位凭证构造 LarkClient 不发网络请求,必成功"); Self { client, streams: Arc::new(tokio::sync::Mutex::new(HashMap::new())), } } } #[async_trait] impl PlatformAdapter for FeishuAdapter { fn name(&self) -> &str { "feishu" } /// 兜底清理流式残留(走查 #3):桌面端异常中止时无 Completed 事件, /// streams entry 永驻导致下轮首帧错覆写旧消息。核心循环在新建会话映射时调用。 async fn reset_stream(&self, chat_id: &str) { self.streams.lock().await.remove(chat_id); } async fn run(&self, events: Sender) -> anyhow::Result<()> { // 长连接模式:空 verification_token/encrypt_key(免公网回调) let dispatcher = EventDispatcher::new("", ""); let client = self.client.clone(); // 入站事件通道复制两份,分别给消息/卡片回调闭包 let events_for_msg = events.clone(); let events_for_card = events; let channel: Channel<'static> = { // Channel<'a> 借用 client;用 Box::leak 提升为 'static(适配器生命周期即进程生命周期) let leaked: &'static LarkClient = Box::leak(Box::new(client)); Channel::builder(leaked, dispatcher) .policy(ChannelPolicy::default().require_mention(false)) .on_message(move |msg| { let events = events_for_msg.clone(); async move { handle_inbound_message(events, msg).await; Ok(()) } }) .on_card_action(move |action| { let events = events_for_card.clone(); async move { handle_card_action(events, action).await; Ok(()) } }) .build() }; tracing::info!("[feishu] 长连接启动(免公网回调)"); channel.start().await.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}"))?; Ok(()) } async fn dispatch(&self, action: OutboundAction) -> anyhow::Result<()> { let opt = &RequestOption::default(); match action { OutboundAction::StreamText { chat_id, text, done } => { // 两段式:锁内只取/写 message_id(纯内存操作),网络 IO 在锁外执行。 // 修复走查 #2:同 chat 仍串行(顺序语义),不同 chat 不再被同一把锁挡住。 let existing_id: Option = { let streams = self.streams.lock().await; streams.get(&chat_id).cloned() }; if let Some(message_id) = existing_id { let res = self.client.channel_messaging().edit_text(&message_id, &text, opt).await; if let Err(e) = res { tracing::warn!("[feishu] 流式更新失败(消息可能已被撤回/超时): {e},回退新发"); self.streams.lock().await.remove(&chat_id); } } else { // 首帧:发新文本消息,记录 message_id 供后续覆写 let input = larksuite_oapi_sdk_rs::channel::SendInput { receive_id: Some(chat_id.clone()), text: Some(text), ..Default::default() }; let send_result = self.client.channel_messaging().send(&input, opt).await; match send_result { Ok(sr) if !sr.message_id.is_empty() => { self.streams.lock().await.insert(chat_id.clone(), sr.message_id); } Ok(_) => { tracing::warn!("[feishu] 发送流式首帧未返回 message_id"); } Err(e) => { tracing::error!("[feishu] 发送流式首帧失败: {e}"); } } } if done { self.streams.lock().await.remove(&chat_id); } Ok(()) } OutboundAction::ApprovalCard { chat_id, approval_id, title, detail } => { let card = build_approval_card(&approval_id, &title, &detail); let document = CardDocument::new(card) .map_err(|e| anyhow::anyhow!("审批卡片校验失败: {e}"))?; // encoded_content 是 SDK pub(crate);直接序列化 card(校验已过) let content = serde_json::to_string(document.card()) .map_err(|e| anyhow::anyhow!("卡片序列化失败: {e}"))?; let input = larksuite_oapi_sdk_rs::channel::SendInput { receive_id: Some(chat_id), card: Some(content), ..Default::default() }; self.client .channel_messaging() .send(&input, opt) .await .map_err(|e| anyhow::anyhow!("审批卡片发送失败: {e}"))?; Ok(()) } OutboundAction::Notice { chat_id, text } => { let input = larksuite_oapi_sdk_rs::channel::SendInput { receive_id: Some(chat_id), text: Some(text), ..Default::default() }; self.client .channel_messaging() .send(&input, opt) .await .map_err(|e| anyhow::anyhow!("通知发送失败: {e}"))?; Ok(()) } } } } /// 入站消息:群聊需 @机器人(mentioned_bot)或单聊直发 async fn handle_inbound_message(events: Sender, msg: larksuite_oapi_sdk_rs::channel::NormalizedMessage) { // 只处理文本(text 优先,回退 content) if msg.message_type != "text" { tracing::debug!("[feishu] 忽略非文本消息 type={}", msg.message_type); return; } // 群聊必须 @机器人;单聊(p2p)直发 if msg.chat_type == "group" && !msg.mentioned_bot { tracing::debug!("[feishu] 群聊未 @机器人,忽略"); return; } let text = msg.text.clone().unwrap_or_default().trim().to_string(); if text.is_empty() { return; } // 指令:/new 重置会话 if text == "/new" { let _ = events .send(InboundEvent::NewSession { platform: "feishu".into(), chat_id: msg.chat_id.clone(), }) .await; return; } let user_id = msg .sender .user_id .as_ref() .and_then(|u| u.open_id.clone()) .unwrap_or_else(|| "unknown".into()); let _ = events .send(InboundEvent::Message { platform: "feishu".into(), chat_id: msg.chat_id, user_id, text, }) .await; } /// 卡片按钮回调:action_value 携带 approval_id + approved async fn handle_card_action(events: Sender, action: larksuite_oapi_sdk_rs::channel::NormalizedCardAction) { // 按钮约定的 value 形态:{"approval_id": "...", "approved": true|false} let approval_id = action .action_value .get("approval_id") .and_then(|v| v.as_str()) .unwrap_or_default() .to_string(); let approved = action .action_value .get("approved") .and_then(|v| v.as_bool()) .unwrap_or(false); if approval_id.is_empty() { tracing::warn!("[feishu] 卡片回调缺 approval_id,忽略"); return; } let chat_id = action.open_chat_id.clone().unwrap_or_default(); let _ = events .send(InboundEvent::Approval { platform: "feishu".into(), chat_id, approval_id, approved, }) .await; } /// 构造审批卡片(Card JSON 1.0):标题 + 详情 + 同意/拒绝按钮 fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card { let detail_md = format!("{}\n\n**审批 ID**: `{approval_id}`", escape_md(detail)); let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue { serde_json::from_value(serde_json::to_value(v).expect("serde_json::Value 可逆")) .expect("按钮 value 结构兼容") }; let actions = Action::new() .action(ActionComponent::Button( Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("✅ 同意")) .button_type(ButtonType::Primary) .value(to_json_value(serde_json::json!({"approval_id": approval_id, "approved": true}))), )) .action(ActionComponent::Button( Button::new(larksuite_oapi_sdk_rs::card::v1::Text::plain("❌ 拒绝")) .button_type(ButtonType::Danger) .value(to_json_value(serde_json::json!({"approval_id": approval_id, "approved": false}))), )); Card::new() .header(Header::new(title)) .element(Element::Div(Div::new( larksuite_oapi_sdk_rs::card::v1::Text::lark_md(detail_md), ))) .element(Element::Markdown(Markdown::new("---"))) .element(Element::Action(actions)) } /// Markdown 转义(飞书卡片 lark_md 语法) fn escape_md(s: &str) -> String { s.replace('\\', "\\\\") .replace('*', "\\*") .replace('_', "\\_") .replace('`', "\\`") .replace('[', "\\[") .replace(']', "\\]") .replace('<', "\\<") .replace('>', "\\>") .replace('(', "\\(") .replace(')', "\\)") .replace('#', "\\#") } #[cfg(test)] mod tests { use super::*; use crate::config::FeishuConfig; /// 构造冒烟:new/offline 均不发网络请求,验证适配器可实例化 #[test] fn feishu_adapter_constructs() { let cfg = FeishuConfig { app_id: "cli_smoke".into(), app_secret: "secret".into(), long_connection: true, }; let adapter = FeishuAdapter::new(&cfg).expect("适配器构造应成功"); assert_eq!(adapter.name(), "feishu"); let offline = FeishuAdapter::offline(); assert_eq!(offline.name(), "feishu"); } /// 审批卡片构造 + 协议校验(CardDocument::new 是发送前的强校验门槛) #[test] fn approval_card_passes_protocol_validation() { let card = build_approval_card("appr-123", "工具审批请求", "run_command: cargo test"); CardDocument::new(card).expect("审批卡片应通过 Card JSON 1.0 校验"); } /// 卡片构造包含同意/拒绝按钮及 approval_id 载荷 #[test] fn approval_card_has_buttons_and_payload() { let card = build_approval_card("appr-456", "标题", "详情"); let json = serde_json::to_value(&card).unwrap(); let elements = json["elements"].as_array().expect("elements 数组"); // 最后一个元素是 Action 行,内含 2 个按钮 let action = elements.last().unwrap(); let buttons = action["actions"].as_array().expect("actions 数组"); assert_eq!(buttons.len(), 2); let values: Vec = buttons .iter() .map(|b| b["value"]["approval_id"].as_str().unwrap_or_default().to_string()) .collect(); assert!(values.iter().all(|v| v == "appr-456")); let approved_flags: Vec = buttons .iter() .map(|b| b["value"]["approved"].as_bool().unwrap_or(false)) .collect(); assert!(approved_flags.contains(&true) && approved_flags.contains(&false)); } /// Markdown 转义:卡片详情中的 markdown 语法字符被转义 #[test] fn escape_md_works() { assert_eq!(escape_md("a*b_c`d\\e"), "a\\*b\\_c\\`d\\\\e"); // 走查 #7:链接/标题/引用语法字符也须转义,防用户内容注入卡片结构 assert_eq!(escape_md("[x](http://e)#h (y)"), "\\[x\\]\\(http://e\\)\\#h \\\\(y\\)"); } }