diff --git a/src-tauri/src/feishu_channel.rs b/src-tauri/src/feishu_channel.rs new file mode 100644 index 0000000..d533bad --- /dev/null +++ b/src-tauri/src/feishu_channel.rs @@ -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, +} + +/// settings 值归一:前端 JSON.stringify 落库带引号,去首尾引号(SettingsNorm 正典同款) +fn norm(raw: Option) -> String { + raw.map(|s| s.trim().trim_matches('"').to_string()) + .unwrap_or_default() +} + +/// 渠道运行上下文(回调闭包共享) +struct Ctx { + app: tauri::AppHandle, + client: LarkClient, + whitelist: Vec, + /// chat_id → conversation_id(启动时从 KV 恢复,变更回写) + chat_map: Arc>>, +} + +/// 启动入口:lib.rs setup 调用。未启用/配置缺失/构造失败一律记日志返回,不阻塞应用启动。 +pub fn start_if_enabled(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + let db = app.state::().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::(&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 = 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, 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, + 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::().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::(); + let (tx, rx) = tokio::sync::oneshot::channel::(); + 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::().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) { + let repo = SettingsRepo::new(db); + let json = serde_json::to_string(map).unwrap_or_default(); + let _ = repo.set(KV_CHAT_MAP, &json).await; +}