//! 飞书适配器(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(&'static,构造期一次性 Box::leak)。D1(u-claw 教训:热重建不关旧 /// 连接→僵尸资源):leak 只在 new()/offline() 各发生一次;run() 重连重入只重建 /// Channel 不再重复 leak,防频繁断线场景 LarkClient 内存阶梯上涨。出站 dispatch /// 与 Channel 构建共用同一份引用。 client: &'static LarkClient, /// 流式会话注册表:chat_id → 已发出的流式消息 message_id(用于 edit_text 覆写) streams: Arc>>, } impl FeishuAdapter { pub fn new(cfg: &FeishuConfig) -> anyhow::Result { let client: &'static LarkClient = Box::leak(Box::new( 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: &'static LarkClient = Box::leak(Box::new( 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; // 入站事件通道复制两份,分别给消息/卡片回调闭包 let events_for_msg = events.clone(); let events_for_card = events; let channel: Channel<'static> = { // client 已是 &'static(构造期一次性 leak);run() 重连重入只重建 Channel, // 不再重复 Box::leak(D1:防每次重入泄漏一份 LarkClient) Channel::builder(client, 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}`", md_to_lark_friendly(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 语法适配)。 /// /// AI 产出常带标准 Markdown 结构,lark_md 不支持标题与表格语法,原样注入按字面量渲染。 /// 规则(与 src-tauri feishu_channel.rs 侧 md_to_lark_friendly 同规则互引,双端出站口径一致): /// 标题行(行首一个以上 #)转粗体行;表格行(| 开头)转列表行,表头分隔行整行跳过; /// 代码块围栏内外均保留原样(转义或改写破坏代码展示);其余行原样透传(lark_md 原生 /// 支持粗体/链接/行内代码,不额外转义)。 fn md_to_lark_friendly(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut in_code = false; for line in s.lines() { if line.trim_start().starts_with("```") { in_code = !in_code; out.push_str(line); out.push('\n'); continue; } if in_code { out.push_str(line); out.push('\n'); continue; } let trimmed = line.trim_start(); if trimmed.starts_with('#') { let title = trimmed.trim_start_matches('#').trim(); if !title.is_empty() { out.push_str(&format!("**{title}**\n")); } continue; } if trimmed.starts_with('|') { let cells: Vec<&str> = trimmed.trim_matches('|').split('|').map(str::trim).collect(); // 表头分隔行(|---|:---|)无内容语义,整行跳过 if cells.iter().all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':')) { continue; } let shown: Vec<&str> = cells.into_iter().filter(|c| !c.is_empty()).collect(); out.push_str(&format!("- {}\n", shown.join(" · "))); continue; } out.push_str(line); out.push('\n'); } // lines() 逐行补换行,源文本不以换行结尾时尾部多出一个,与源对齐 if !s.ends_with('\n') { out.pop(); } out } #[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)); } /// 出站净化:标题转粗体行,表格行转列表行,表头分隔行跳过 #[test] fn lark_friendly_converts_headings_and_tables() { let md = "# 标题\n| a | b |\n|---|---|\n| 1 | 2 |"; assert_eq!(md_to_lark_friendly(md), "**标题**\n- a · b\n- 1 · 2"); } /// 出站净化:代码围栏内外均保留原样,围栏外的标题照常改写 #[test] fn lark_friendly_preserves_code_fence() { let md = "前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n## 后文"; assert_eq!( md_to_lark_friendly(md), "前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n**后文**" ); } /// 出站净化:普通行原样透传(lark_md 原生支持粗体/链接,不额外转义) #[test] fn lark_friendly_passes_plain_lines() { let md = "**粗** 与 [链接](http://e)\n普通行"; assert_eq!(md_to_lark_friendly(md), md); } }