351 lines
14 KiB
Rust
351 lines
14 KiB
Rust
//! 飞书适配器(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<tokio::sync::Mutex<HashMap<String, String>>>,
|
|
}
|
|
|
|
impl FeishuAdapter {
|
|
pub fn new(cfg: &FeishuConfig) -> anyhow::Result<Self> {
|
|
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<InboundEvent>) -> 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<String> = {
|
|
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<InboundEvent>, 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<InboundEvent>, 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<String> = 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<bool> = 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 <b>(y)"), "\\[x\\]\\(http://e\\)\\#h \\<b\\>\\(y\\)");
|
|
}
|
|
}
|