新增: 飞书直连渠道接线(文本链路第一批)
- 桌面端出站WS长连接,复用spawn_agentic_session通用核心与完成通知桥 - 配置读feishu-config KV,白名单空=放行并自助提示open_id - todo并入下午分析产出(存储架构/知识库/规范治理/基建走查)
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
//! 飞书直连渠道:桌面端出站 WS 长连接(免公网回调),消息进 ai-chat 会话链路
|
||||
//!
|
||||
//! 形态依据:docs/02-架构设计/专项设计/飞书直连接入-形态修正与问题记录-2026-10-05.md
|
||||
//! (用户 10-05 推翻网关链路,定稿桌面本地直连;参照 u-claw feishu_channel.go 单进程闭环)
|
||||
//! 内核自 crates/df-im-gateway/src/feishu.rs 提炼(用户令:并入现有,不新建 crate);
|
||||
//! 会话复用 spawn_agentic_session 通用核心(pin_active=false,与编排节点同款调用方),
|
||||
//! 完成通知走 AppState 通知桥(register/notify_agent_completion,编排节点先例)。
|
||||
//!
|
||||
//! 第一批:文本收发(整段回复);流式 edit_text/审批卡片放第二批(内核留存于网关 feishu.rs)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::Manager;
|
||||
use larksuite_oapi_sdk_rs::channel::{Channel, ChannelPolicy, SendInput};
|
||||
use larksuite_oapi_sdk_rs::{EventDispatcher, LarkClient, LarkError, RequestOption};
|
||||
use serde::Deserialize;
|
||||
|
||||
use df_storage::crud::SettingsRepo;
|
||||
use df_types::types::new_id;
|
||||
|
||||
/// app_settings 键:飞书渠道配置(与设置面板 FeishuPanel 同键契约)
|
||||
const KV_FEISHU_CONFIG: &str = "feishu-config";
|
||||
/// app_settings 键:飞书 chat_id → conversation_id 映射(持久化,跨重启续会话)
|
||||
const KV_CHAT_MAP: &str = "feishu-chat-map";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct FeishuConfig {
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
app_id: String,
|
||||
#[serde(default)]
|
||||
app_secret: String,
|
||||
/// 空 = 放行所有(首启抓 open_id 回填);open_id 按应用隔离,严禁跨应用搬运
|
||||
#[serde(default)]
|
||||
whitelist: Vec<String>,
|
||||
}
|
||||
|
||||
/// settings 值归一:前端 JSON.stringify 落库带引号,去首尾引号(SettingsNorm 正典同款)
|
||||
fn norm(raw: Option<String>) -> String {
|
||||
raw.map(|s| s.trim().trim_matches('"').to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 渠道运行上下文(回调闭包共享)
|
||||
struct Ctx {
|
||||
app: tauri::AppHandle,
|
||||
client: LarkClient,
|
||||
whitelist: Vec<String>,
|
||||
/// chat_id → conversation_id(启动时从 KV 恢复,变更回写)
|
||||
chat_map: Arc<tokio::sync::Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
/// 启动入口:lib.rs setup 调用。未启用/配置缺失/构造失败一律记日志返回,不阻塞应用启动。
|
||||
pub fn start_if_enabled(app: tauri::AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let db = app.state::<crate::state::AppState>().db.clone();
|
||||
let repo = SettingsRepo::new(&db);
|
||||
let raw = norm(repo.get(KV_FEISHU_CONFIG).await.ok().flatten());
|
||||
if raw.is_empty() {
|
||||
tracing::info!("[feishu] feishu-config 未配置,渠道不启动");
|
||||
return;
|
||||
}
|
||||
let Ok(cfg) = serde_json::from_str::<FeishuConfig>(&raw) else {
|
||||
tracing::error!("[feishu] feishu-config 解析失败,渠道不启动");
|
||||
return;
|
||||
};
|
||||
if !cfg.enabled || cfg.app_id.is_empty() || cfg.app_secret.is_empty() {
|
||||
tracing::info!("[feishu] enabled=false 或凭证缺失,渠道不启动");
|
||||
return;
|
||||
}
|
||||
let client = match LarkClient::builder(&cfg.app_id, &cfg.app_secret).build() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("[feishu] LarkClient 构造失败: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// 启动时从 KV 恢复 chat 映射
|
||||
let restored: HashMap<String, String> = serde_json::from_str(
|
||||
&norm(repo.get(KV_CHAT_MAP).await.ok().flatten()),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let ctx = Arc::new(Ctx {
|
||||
app,
|
||||
client: client.clone(),
|
||||
whitelist: cfg.whitelist,
|
||||
chat_map: Arc::new(tokio::sync::Mutex::new(restored)),
|
||||
});
|
||||
if let Err(e) = run_loop(ctx, client).await {
|
||||
tracing::error!("[feishu] 长连接退出: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_loop(ctx: Arc<Ctx>, client: LarkClient) -> anyhow::Result<()> {
|
||||
let dispatcher = EventDispatcher::new("", "");
|
||||
let ctx_msg = ctx.clone();
|
||||
// Channel<'a> 借用 client;Box::leak 提升为 'static(渠道生命周期即进程生命周期)
|
||||
let leaked: &'static LarkClient = Box::leak(Box::new(client));
|
||||
let channel: Channel<'static> = Channel::builder(leaked, dispatcher)
|
||||
.policy(ChannelPolicy::default().require_mention(false))
|
||||
.on_message(move |msg| {
|
||||
let ctx = ctx_msg.clone();
|
||||
async move {
|
||||
handle_message(ctx, msg).await;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.build();
|
||||
tracing::info!("[feishu] 长连接启动(桌面端直连,免公网回调)");
|
||||
channel
|
||||
.start()
|
||||
.await
|
||||
.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 入站文本:群聊需 @bot;白名单校验;/new 重置;其余走 ai-chat 会话链路
|
||||
async fn handle_message(
|
||||
ctx: Arc<Ctx>,
|
||||
msg: larksuite_oapi_sdk_rs::channel::NormalizedMessage,
|
||||
) {
|
||||
if msg.message_type != "text" {
|
||||
return;
|
||||
}
|
||||
if msg.chat_type == "group" && !msg.mentioned_bot {
|
||||
return;
|
||||
}
|
||||
let text = msg.text.clone().unwrap_or_default().trim().to_string();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let chat_id = msg.chat_id.clone();
|
||||
let open_id = msg
|
||||
.sender
|
||||
.user_id
|
||||
.as_ref()
|
||||
.and_then(|u| u.open_id.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 白名单:空=放行;非空且不在内 → 提示并附自己的 open_id(首启自助回填,免翻日志)
|
||||
if !ctx.whitelist.is_empty() && !ctx.whitelist.iter().any(|w| w == &open_id) {
|
||||
let hint = format!("⛔ 你不在白名单内。你的 open_id:`{open_id}`(发给管理员回填后重试)");
|
||||
send_text(&ctx.client, &chat_id, &hint).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// /new 重置该飞书会话映射(下次消息开新 conv)
|
||||
if text == "/new" {
|
||||
ctx.chat_map.lock().await.remove(&chat_id);
|
||||
let db = ctx.app.state::<crate::state::AppState>().db.clone();
|
||||
let snapshot = ctx.chat_map.lock().await.clone();
|
||||
save_chat_map(&db, &snapshot).await;
|
||||
send_text(&ctx.client, &chat_id, "✅ 已重置会话").await;
|
||||
return;
|
||||
}
|
||||
|
||||
// chat_id → conv_id 映射(内存命中优先,未命中新建)
|
||||
let conv_id = {
|
||||
let mut map = ctx.chat_map.lock().await;
|
||||
map.entry(chat_id.clone()).or_insert_with(new_id).clone()
|
||||
};
|
||||
|
||||
let state = ctx.app.state::<crate::state::AppState>();
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<String>();
|
||||
state.register_agent_completion(conv_id.clone(), tx);
|
||||
|
||||
match crate::commands::ai::commands::chat::spawn_agentic_session(
|
||||
&ctx.app,
|
||||
&state,
|
||||
text,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(conv_id.clone()),
|
||||
None,
|
||||
None,
|
||||
false, // 不劫持用户活跃面板(编排节点同款语义)
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let db = ctx.app.state::<crate::state::AppState>().db.clone();
|
||||
let snapshot = ctx.chat_map.lock().await.clone();
|
||||
save_chat_map(&db, &snapshot).await;
|
||||
}
|
||||
Err(e) => {
|
||||
// 启动失败:通知桥条目清掉防死条目(编排节点同款)
|
||||
state.notify_agent_completion(&conv_id, String::new());
|
||||
send_text(&ctx.client, &chat_id, &format!("❌ 会话启动失败: {e}")).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 等最终回复(通知桥 oneshot;超时兜底防悬挂)
|
||||
let reply = match tokio::time::timeout(std::time::Duration::from_secs(600), rx).await {
|
||||
Ok(Ok(text)) if !text.trim().is_empty() => text,
|
||||
Ok(_) => "(会话已结束,无文本回复)".to_string(),
|
||||
Err(_) => "⏱ 回复超时,请重试或 /new 重置".to_string(),
|
||||
};
|
||||
send_text(&ctx.client, &chat_id, &reply).await;
|
||||
}
|
||||
|
||||
async fn send_text(client: &LarkClient, chat_id: &str, text: &str) {
|
||||
let input = SendInput {
|
||||
receive_id: Some(chat_id.to_string()),
|
||||
text: Some(text.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(e) = client.channel_messaging().send(&input, &RequestOption::default()).await {
|
||||
tracing::error!("[feishu] 发送失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, String>) {
|
||||
let repo = SettingsRepo::new(db);
|
||||
let json = serde_json::to_string(map).unwrap_or_default();
|
||||
let _ = repo.set(KV_CHAT_MAP, &json).await;
|
||||
}
|
||||
Reference in New Issue
Block a user