diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c48f81d..afd9322 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -386,7 +386,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tower", "tower-layer", "tower-service", @@ -850,6 +850,7 @@ name = "codeg" version = "0.5.3" dependencies = [ "agent-client-protocol-schema", + "async-trait", "axum", "base64 0.22.1", "bzip2", @@ -858,11 +859,13 @@ dependencies = [ "fix-path-env", "flate2", "futures", + "futures-util", "keyring", "kill_tree", "mac-notification-sys", "notify", "portable-pty", + "prost", "regex", "reqwest 0.12.28", "sacp", @@ -882,6 +885,7 @@ dependencies = [ "tauri-plugin-window-state", "thiserror 2.0.18", "tokio", + "tokio-tungstenite 0.26.2", "toml 0.8.2", "tower-http", "urlencoding", @@ -4292,6 +4296,29 @@ dependencies = [ "yansi", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -6731,6 +6758,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.26.2", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -6740,7 +6781,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.28.0", ] [[package]] @@ -6984,6 +7025,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.28.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 365a4d0..92871cc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,7 @@ tauri-build = { version = "2", features = [], optional = true } tauri = { version = "2", features = [], optional = true } tauri-plugin-opener = { version = "2", optional = true } tauri-plugin-dialog = { version = "2", optional = true } +async-trait = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } @@ -77,6 +78,9 @@ which = "7" keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true } axum = { version = "0.8", features = ["ws"] } tower-http = { version = "0.6", features = ["fs", "cors"] } +tokio-tungstenite = { version = "0.26", features = ["native-tls"] } +futures-util = "0.3" +prost = "0.13" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-window-state = { version = "2", optional = true } diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index fc241e1..e9ddfc8 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::sync::Arc; use crate::acp::manager::ConnectionManager; +use crate::chat_channel::manager::ChatChannelManager; use crate::db::AppDatabase; use crate::terminal::manager::TerminalManager; use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster}; @@ -15,6 +16,7 @@ pub struct AppState { pub emitter: EventEmitter, pub data_dir: PathBuf, pub web_server_state: WebServerState, + pub chat_channel_manager: ChatChannelManager, } pub fn default_connection_manager() -> ConnectionManager { @@ -24,3 +26,7 @@ pub fn default_connection_manager() -> ConnectionManager { pub fn default_terminal_manager() -> TerminalManager { TerminalManager::new() } + +pub fn default_chat_channel_manager() -> ChatChannelManager { + ChatChannelManager::new() +} diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 6a27891..329a14c 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -45,8 +45,15 @@ async fn main() { emitter, data_dir, web_server_state: WebServerState::new(), + chat_channel_manager: codeg_lib::app_state::default_chat_channel_manager(), }); + // Start chat channel background tasks (event subscriber, command dispatcher, scheduler, auto-connect) + state + .chat_channel_manager + .start_background(state.event_broadcaster.clone(), state.db.conn.clone()) + .await; + // Build router let router = codeg_lib::web::router::build_router(state, token.clone(), static_dir); diff --git a/src-tauri/src/chat_channel/backends/lark.rs b/src-tauri/src/chat_channel/backends/lark.rs new file mode 100644 index 0000000..fb3f8a0 --- /dev/null +++ b/src-tauri/src/chat_channel/backends/lark.rs @@ -0,0 +1,622 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use futures_util::{SinkExt, StreamExt}; +use prost::Message as ProstMessage; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, Mutex, RwLock}; +use tokio_tungstenite::tungstenite; + +use crate::chat_channel::error::ChatChannelError; +use crate::chat_channel::traits::ChatChannelBackend; +use crate::chat_channel::types::*; + +const FEISHU_BASE_URL: &str = "https://open.feishu.cn"; +const TOKEN_REFRESH_MARGIN_SECS: u64 = 300; + +// ── Lark WebSocket protobuf Frame (pbbp2) ── +// Source: larksuite/oapi-sdk-go ws/pbbp2.pb.go + +const FRAME_METHOD_CONTROL: i32 = 0; // Ping/Pong +const FRAME_METHOD_DATA: i32 = 1; // Event/Card + +#[derive(Clone, PartialEq, ProstMessage)] +struct Frame { + #[prost(uint64, tag = 1)] + seq_id: u64, + #[prost(uint64, tag = 2)] + log_id: u64, + #[prost(int32, tag = 3)] + service: i32, + #[prost(int32, tag = 4)] + method: i32, + #[prost(message, repeated, tag = 5)] + headers: Vec, + #[prost(string, tag = 6)] + payload_encoding: String, + #[prost(string, tag = 7)] + payload_type: String, + #[prost(bytes = "vec", tag = 8)] + payload: Vec, + #[prost(string, tag = 9)] + log_id_new: String, +} + +#[derive(Clone, PartialEq, ProstMessage)] +struct FrameHeader { + #[prost(string, tag = 1)] + key: String, + #[prost(string, tag = 2)] + value: String, +} + +impl Frame { + fn get_header(&self, key: &str) -> Option<&str> { + self.headers + .iter() + .find(|h| h.key == key) + .map(|h| h.value.as_str()) + } + + fn set_header(&mut self, key: &str, value: &str) { + if let Some(h) = self.headers.iter_mut().find(|h| h.key == key) { + h.value = value.to_string(); + } else { + self.headers.push(FrameHeader { + key: key.to_string(), + value: value.to_string(), + }); + } + } +} + +// ── Lark REST API types ── + +#[derive(Deserialize)] +struct TenantAccessTokenResponse { + code: i32, + msg: String, + tenant_access_token: Option, + expire: Option, +} + +#[derive(Serialize)] +struct SendMessageRequest { + receive_id: String, + msg_type: String, + content: String, +} + +#[derive(Deserialize)] +struct SendMessageResponse { + code: i32, + msg: String, + data: Option, +} + +#[derive(Deserialize)] +struct SendMessageData { + message_id: Option, +} + +#[derive(Deserialize)] +struct WsConnectResponse { + code: i32, + msg: String, + data: Option, +} + +#[derive(Deserialize)] +struct WsConnectData { + #[serde(rename = "URL")] + url: Option, +} + +// ── Token cache ── + +struct TokenCache { + token: String, + expires_at: Instant, +} + +// ── Multi-part frame cache ── + +struct PartialMessage { + parts: HashMap>, + total: i32, +} + +// ── LarkBackend ── + +pub struct LarkBackend { + app_id: String, + app_secret: String, + chat_id: String, + channel_id: i32, + client: reqwest::Client, + token_cache: Arc>>, + status: Arc>, + shutdown_tx: Arc>>>, +} + +impl LarkBackend { + pub fn new(channel_id: i32, app_id: String, app_secret: String, chat_id: String) -> Self { + Self { + app_id, + app_secret, + chat_id, + channel_id, + client: reqwest::Client::new(), + token_cache: Arc::new(RwLock::new(None)), + status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)), + shutdown_tx: Arc::new(Mutex::new(None)), + } + } + + async fn get_tenant_access_token(&self) -> Result { + { + let cache = self.token_cache.read().await; + if let Some(cached) = cache.as_ref() { + if cached.expires_at > Instant::now() { + return Ok(cached.token.clone()); + } + } + } + + let resp = self + .client + .post(format!( + "{}/open-apis/auth/v3/tenant_access_token/internal", + FEISHU_BASE_URL + )) + .json(&serde_json::json!({ + "app_id": self.app_id, + "app_secret": self.app_secret, + })) + .send() + .await + .map_err(|e| ChatChannelError::AuthenticationFailed(e.to_string()))?; + + let result: TenantAccessTokenResponse = resp + .json() + .await + .map_err(|e| ChatChannelError::AuthenticationFailed(e.to_string()))?; + + if result.code != 0 { + return Err(ChatChannelError::AuthenticationFailed(format!( + "code={}, msg={}", + result.code, result.msg + ))); + } + + let token = result + .tenant_access_token + .ok_or_else(|| { + ChatChannelError::AuthenticationFailed("No token in response".into()) + })?; + let expire_secs = result.expire.unwrap_or(7200); + + let expires_at = Instant::now() + + Duration::from_secs(expire_secs.saturating_sub(TOKEN_REFRESH_MARGIN_SECS)); + *self.token_cache.write().await = Some(TokenCache { + token: token.clone(), + expires_at, + }); + + Ok(token) + } + + async fn send_lark_message( + &self, + msg_type: &str, + content: &str, + ) -> Result { + let token = self.get_tenant_access_token().await?; + + let resp = self + .client + .post(format!( + "{}/open-apis/im/v1/messages?receive_id_type=chat_id", + FEISHU_BASE_URL + )) + .header("Authorization", format!("Bearer {}", token)) + .json(&SendMessageRequest { + receive_id: self.chat_id.clone(), + msg_type: msg_type.to_string(), + content: content.to_string(), + }) + .send() + .await + .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; + + let result: SendMessageResponse = resp + .json() + .await + .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; + + if result.code != 0 { + return Err(ChatChannelError::SendFailed(format!( + "code={}, msg={}", + result.code, result.msg + ))); + } + + let message_id = result + .data + .and_then(|d| d.message_id) + .unwrap_or_default(); + Ok(SentMessageId(message_id)) + } + + async fn start_ws_receiver( + &self, + command_tx: mpsc::Sender, + ) -> Result<(), ChatChannelError> { + // Verify we can get a WS URL before spawning the background task + let _ = fetch_ws_url(&self.client, &self.app_id, &self.app_secret).await?; + + let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); + *self.shutdown_tx.lock().await = Some(shutdown_tx); + + let channel_id = self.channel_id; + let status = self.status.clone(); + let app_id = self.app_id.clone(); + let app_secret = self.app_secret.clone(); + let client = self.client.clone(); + + tokio::spawn(async move { + let mut retry_count = 0u32; + + loop { + if *shutdown_rx.borrow() { + break; + } + + let ws_url = match fetch_ws_url(&client, &app_id, &app_secret).await { + Ok(url) => url, + Err(e) => { + eprintln!("[Lark] failed to get WS endpoint: {e}"); + *status.lock().await = ChannelConnectionStatus::Error; + let delay = Duration::from_secs((2u64).pow(retry_count.min(5))); + retry_count += 1; + tokio::select! { + _ = tokio::time::sleep(delay) => continue, + _ = shutdown_rx.changed() => break, + } + } + }; + + let ws_result = tokio_tungstenite::connect_async(&ws_url).await; + let ws_stream = match ws_result { + Ok((stream, _)) => { + *status.lock().await = ChannelConnectionStatus::Connected; + retry_count = 0; + eprintln!("[Lark] WebSocket connected"); + stream + } + Err(e) => { + eprintln!("[Lark] WebSocket connect failed: {e}"); + *status.lock().await = ChannelConnectionStatus::Error; + let delay = Duration::from_secs((2u64).pow(retry_count.min(5))); + retry_count += 1; + tokio::select! { + _ = tokio::time::sleep(delay) => continue, + _ = shutdown_rx.changed() => break, + } + } + }; + + let (mut write, mut read) = ws_stream.split(); + let mut partial_msgs: HashMap = HashMap::new(); + + loop { + tokio::select! { + msg = read.next() => { + match msg { + Some(Ok(tungstenite::Message::Binary(data))) => { + match Frame::decode(data.as_ref()) { + Ok(frame) => { + let frame_type = frame.get_header("type").unwrap_or("").to_string(); + + if frame.method == FRAME_METHOD_CONTROL { + // Control frame: ping → respond with pong + if frame_type == "ping" { + let mut pong = frame.clone(); + // Clear type header and set to pong + pong.set_header("type", "pong"); + pong.payload = Vec::new(); + let mut buf = Vec::new(); + if pong.encode(&mut buf).is_ok() { + let _ = write.send(tungstenite::Message::Binary(buf.into())).await; + } + } + } else if frame.method == FRAME_METHOD_DATA && frame_type == "event" { + let start = Instant::now(); + + // Multi-part reassembly + let msg_id = frame.get_header("message_id").unwrap_or("").to_string(); + let sum: i32 = frame.get_header("sum").and_then(|s| s.parse().ok()).unwrap_or(1); + let seq: i32 = frame.get_header("seq").and_then(|s| s.parse().ok()).unwrap_or(0); + + let full_payload = if sum <= 1 { + Some(frame.payload.clone()) + } else { + let entry = partial_msgs.entry(msg_id.clone()).or_insert_with(|| PartialMessage { + parts: HashMap::new(), + total: sum, + }); + entry.parts.insert(seq, frame.payload.clone()); + if entry.parts.len() as i32 >= entry.total { + // All parts received — reassemble in order + let mut combined = Vec::new(); + for i in 0..entry.total { + if let Some(part) = entry.parts.get(&i) { + combined.extend_from_slice(part); + } + } + partial_msgs.remove(&msg_id); + Some(combined) + } else { + None // Still waiting for more parts + } + }; + + if let Some(payload_bytes) = full_payload { + // Process event + if let Ok(payload_str) = std::str::from_utf8(&payload_bytes) { + if let Ok(event) = serde_json::from_str::(payload_str) { + handle_lark_event(&event, channel_id, &command_tx).await; + } else { + eprintln!("[Lark] event payload is not valid JSON"); + } + } + + // Send acknowledgment: echo frame back with {"code":200} + let elapsed_ms = start.elapsed().as_millis(); + let mut ack = frame.clone(); + ack.payload = br#"{"code":200}"#.to_vec(); + ack.set_header("biz_rt", &elapsed_ms.to_string()); + let mut buf = Vec::new(); + if ack.encode(&mut buf).is_ok() { + let _ = write.send(tungstenite::Message::Binary(buf.into())).await; + } + } + } + } + Err(e) => { + eprintln!("[Lark] protobuf decode error: {e}, len={}", data.len()); + } + } + } + Some(Ok(tungstenite::Message::Ping(data))) => { + let _ = write.send(tungstenite::Message::Pong(data)).await; + } + Some(Ok(tungstenite::Message::Close(_))) | None => { + eprintln!("[Lark] WebSocket closed, will reconnect"); + break; + } + Some(Err(e)) => { + eprintln!("[Lark] WebSocket error: {e}"); + break; + } + _ => {} + } + } + _ = shutdown_rx.changed() => { + let _ = write.close().await; + *status.lock().await = ChannelConnectionStatus::Disconnected; + return; + } + } + } + + *status.lock().await = ChannelConnectionStatus::Connecting; + let delay = Duration::from_secs(3); + tokio::select! { + _ = tokio::time::sleep(delay) => {}, + _ = shutdown_rx.changed() => break, + } + } + + *status.lock().await = ChannelConnectionStatus::Disconnected; + }); + + Ok(()) + } +} + +async fn handle_lark_event( + event: &serde_json::Value, + channel_id: i32, + command_tx: &mpsc::Sender, +) { + let event_type = event + .pointer("/header/event_type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if event_type == "im.message.receive_v1" { + let msg_type = event + .pointer("/event/message/message_type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if msg_type != "text" { + return; + } + + let content_str = event + .pointer("/event/message/content") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Content is JSON string: {"text":"actual message"} + let text = serde_json::from_str::(content_str) + .ok() + .and_then(|v| v.get("text").and_then(|t| t.as_str()).map(String::from)) + .unwrap_or_default(); + + if text.is_empty() { + return; + } + + let sender_id = event + .pointer("/event/sender/sender_id/open_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + eprintln!("[Lark] incoming message from {}: {}", sender_id, text); + + let _ = command_tx + .send(IncomingCommand { + channel_id, + sender_id, + command_text: text, + metadata: event.clone(), + }) + .await; + } +} + +/// Fetch a fresh WebSocket endpoint URL from Feishu. +async fn fetch_ws_url( + client: &reqwest::Client, + app_id: &str, + app_secret: &str, +) -> Result { + let resp = client + .post(format!("{}/callback/ws/endpoint", FEISHU_BASE_URL)) + .json(&serde_json::json!({ + "AppID": app_id, + "AppSecret": app_secret, + })) + .send() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; + + let ws_resp: WsConnectResponse = resp + .json() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; + + if ws_resp.code != 0 { + return Err(ChatChannelError::ConnectionFailed(format!( + "WS connect failed: code={}, msg={}", + ws_resp.code, ws_resp.msg + ))); + } + + ws_resp + .data + .and_then(|d| d.url) + .ok_or_else(|| ChatChannelError::ConnectionFailed("No WebSocket URL returned".into())) +} + +#[async_trait] +impl ChatChannelBackend for LarkBackend { + fn channel_type(&self) -> ChannelType { + ChannelType::Lark + } + + async fn start( + &self, + command_tx: mpsc::Sender, + ) -> Result<(), ChatChannelError> { + *self.status.lock().await = ChannelConnectionStatus::Connecting; + self.get_tenant_access_token().await?; + *self.status.lock().await = ChannelConnectionStatus::Connected; + + if let Err(e) = self.start_ws_receiver(command_tx).await { + eprintln!("[Lark] WebSocket receiver failed to start: {e}"); + } + + Ok(()) + } + + async fn stop(&self) -> Result<(), ChatChannelError> { + if let Some(tx) = self.shutdown_tx.lock().await.take() { + let _ = tx.send(true); + } + *self.status.lock().await = ChannelConnectionStatus::Disconnected; + Ok(()) + } + + async fn status(&self) -> ChannelConnectionStatus { + *self.status.lock().await + } + + async fn send_message(&self, text: &str) -> Result { + let content = serde_json::json!({ "text": text }).to_string(); + self.send_lark_message("text", &content).await + } + + async fn send_rich_message( + &self, + message: &RichMessage, + ) -> Result { + let card = build_lark_card(message); + let content = serde_json::to_string(&card) + .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; + self.send_lark_message("interactive", &content).await + } + + async fn test_connection(&self) -> Result<(), ChatChannelError> { + self.get_tenant_access_token().await?; + Ok(()) + } +} + +fn build_lark_card(msg: &RichMessage) -> serde_json::Value { + let header_color = match msg.level { + MessageLevel::Info => "blue", + MessageLevel::Warning => "orange", + MessageLevel::Error => "red", + }; + + let title = msg.title.as_deref().unwrap_or("Codeg"); + + let mut elements: Vec = Vec::new(); + + if !msg.body.is_empty() { + elements.push(serde_json::json!({ + "tag": "markdown", + "content": msg.body, + })); + } + + if !msg.fields.is_empty() { + let field_elements: Vec = msg + .fields + .iter() + .map(|(k, v)| { + serde_json::json!({ + "is_short": true, + "text": { + "tag": "lark_md", + "content": format!("**{}**\n{}", k, v), + } + }) + }) + .collect(); + + elements.push(serde_json::json!({ + "tag": "div", + "fields": field_elements, + })); + } + + serde_json::json!({ + "config": { "wide_screen_mode": true }, + "header": { + "title": { + "tag": "plain_text", + "content": title, + }, + "template": header_color, + }, + "elements": elements, + }) +} diff --git a/src-tauri/src/chat_channel/backends/mod.rs b/src-tauri/src/chat_channel/backends/mod.rs new file mode 100644 index 0000000..84a386d --- /dev/null +++ b/src-tauri/src/chat_channel/backends/mod.rs @@ -0,0 +1,2 @@ +pub mod lark; +pub mod telegram; diff --git a/src-tauri/src/chat_channel/backends/telegram.rs b/src-tauri/src/chat_channel/backends/telegram.rs new file mode 100644 index 0000000..bf448a1 --- /dev/null +++ b/src-tauri/src/chat_channel/backends/telegram.rs @@ -0,0 +1,255 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{mpsc, Mutex}; + +use crate::chat_channel::error::ChatChannelError; +use crate::chat_channel::traits::ChatChannelBackend; +use crate::chat_channel::types::*; + +pub struct TelegramBackend { + bot_token: String, + chat_id: String, + client: reqwest::Client, + status: Arc>, + channel_id: i32, + shutdown_tx: Arc>>>, +} + +impl TelegramBackend { + pub fn new(channel_id: i32, bot_token: String, chat_id: String) -> Self { + Self { + bot_token, + chat_id, + client: reqwest::Client::new(), + status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)), + channel_id, + shutdown_tx: Arc::new(Mutex::new(None)), + } + } + + fn api_url(&self, method: &str) -> String { + format!( + "https://api.telegram.org/bot{}/{}", + self.bot_token, method + ) + } +} + +#[async_trait] +impl ChatChannelBackend for TelegramBackend { + fn channel_type(&self) -> ChannelType { + ChannelType::Telegram + } + + async fn start( + &self, + command_tx: mpsc::Sender, + ) -> Result<(), ChatChannelError> { + *self.status.lock().await = ChannelConnectionStatus::Connecting; + + // Verify bot token by calling getMe + let resp = self + .client + .get(&self.api_url("getMe")) + .send() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; + + if !resp.status().is_success() { + *self.status.lock().await = ChannelConnectionStatus::Error; + return Err(ChatChannelError::AuthenticationFailed( + "Invalid bot token".to_string(), + )); + } + + *self.status.lock().await = ChannelConnectionStatus::Connected; + + // Start long-polling loop + let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); + *self.shutdown_tx.lock().await = Some(shutdown_tx); + + let client = self.client.clone(); + let bot_token = self.bot_token.clone(); + let channel_id = self.channel_id; + let status = self.status.clone(); + + tokio::spawn(async move { + let mut offset: i64 = 0; + loop { + if *shutdown_rx.borrow() { + break; + } + + let url = format!( + "https://api.telegram.org/bot{}/getUpdates?timeout=30&offset={}", + bot_token, offset + ); + + let result = tokio::select! { + r = client.get(&url).send() => r, + _ = shutdown_rx.changed() => break, + }; + + match result { + Ok(resp) => { + if let Ok(body) = resp.json::().await { + if let Some(updates) = body.get("result").and_then(|r| r.as_array()) { + for update in updates { + if let Some(uid) = + update.get("update_id").and_then(|u| u.as_i64()) + { + offset = uid + 1; + } + if let Some(text) = update + .pointer("/message/text") + .and_then(|t| t.as_str()) + { + let sender_id = update + .pointer("/message/from/id") + .and_then(|i| i.as_i64()) + .map(|i| i.to_string()) + .unwrap_or_default(); + let _ = command_tx + .send(IncomingCommand { + channel_id, + sender_id, + command_text: text.to_string(), + metadata: update.clone(), + }) + .await; + } + } + } + } + } + Err(e) => { + eprintln!("[Telegram] polling error: {e}"); + *status.lock().await = ChannelConnectionStatus::Error; + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + *status.lock().await = ChannelConnectionStatus::Connected; + } + } + } + *status.lock().await = ChannelConnectionStatus::Disconnected; + }); + + Ok(()) + } + + async fn stop(&self) -> Result<(), ChatChannelError> { + if let Some(tx) = self.shutdown_tx.lock().await.take() { + let _ = tx.send(true); + } + *self.status.lock().await = ChannelConnectionStatus::Disconnected; + Ok(()) + } + + async fn status(&self) -> ChannelConnectionStatus { + *self.status.lock().await + } + + async fn send_message(&self, text: &str) -> Result { + let body = serde_json::json!({ + "chat_id": self.chat_id, + "text": text, + "parse_mode": "Markdown", + }); + + let resp = self + .client + .post(&self.api_url("sendMessage")) + .json(&body) + .send() + .await + .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; + + let result: serde_json::Value = resp + .json() + .await + .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; + + let message_id = result + .pointer("/result/message_id") + .and_then(|v| v.as_i64()) + .map(|id| id.to_string()) + .unwrap_or_default(); + + Ok(SentMessageId(message_id)) + } + + async fn send_rich_message( + &self, + message: &RichMessage, + ) -> Result { + let text = format_telegram_markdown(message); + self.send_message(&text).await + } + + async fn test_connection(&self) -> Result<(), ChatChannelError> { + let resp = self + .client + .get(&self.api_url("getMe")) + .send() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; + + if resp.status().is_success() { + Ok(()) + } else { + Err(ChatChannelError::AuthenticationFailed( + "Invalid bot token".to_string(), + )) + } + } +} + +fn format_telegram_markdown(msg: &RichMessage) -> String { + let mut text = String::new(); + + let level_emoji = match msg.level { + MessageLevel::Info => "ℹ️", + MessageLevel::Warning => "⚠️", + MessageLevel::Error => "❌", + }; + + if let Some(title) = &msg.title { + text.push_str(&format!("{} *{}*\n", level_emoji, escape_markdown(title))); + } + + text.push_str(&escape_markdown(&msg.body)); + + if !msg.fields.is_empty() { + text.push('\n'); + for (key, value) in &msg.fields { + text.push_str(&format!( + "\n*{}*: {}", + escape_markdown(key), + escape_markdown(value) + )); + } + } + + text +} + +fn escape_markdown(text: &str) -> String { + text.replace('_', "\\_") + .replace('*', "\\*") + .replace('[', "\\[") + .replace(']', "\\]") + .replace('(', "\\(") + .replace(')', "\\)") + .replace('~', "\\~") + .replace('`', "\\`") + .replace('>', "\\>") + .replace('#', "\\#") + .replace('+', "\\+") + .replace('-', "\\-") + .replace('=', "\\=") + .replace('|', "\\|") + .replace('{', "\\{") + .replace('}', "\\}") + .replace('.', "\\.") + .replace('!', "\\!") +} diff --git a/src-tauri/src/chat_channel/command_dispatcher.rs b/src-tauri/src/chat_channel/command_dispatcher.rs new file mode 100644 index 0000000..90ac44f --- /dev/null +++ b/src-tauri/src/chat_channel/command_dispatcher.rs @@ -0,0 +1,97 @@ +use sea_orm::DatabaseConnection; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use super::command_handlers; +use super::manager::ChatChannelManager; +use super::types::IncomingCommand; +use crate::db::service::chat_channel_message_log_service; + +pub fn spawn_command_dispatcher( + mut command_rx: mpsc::Receiver, + manager: ChatChannelManager, + db_conn: DatabaseConnection, +) -> JoinHandle<()> { + tokio::spawn(async move { + while let Some(cmd) = command_rx.recv().await { + let text = cmd.command_text.trim(); + + // Log inbound command + let _ = chat_channel_message_log_service::create_log( + &db_conn, + cmd.channel_id, + "inbound", + "command_query", + text, + "sent", + None, + ) + .await; + + let response = dispatch_command(text, &db_conn, &manager).await; + + // Send response back via the same channel + let send_result = manager.send_to_channel(cmd.channel_id, &response).await; + let (status, error_detail) = match &send_result { + Ok(_) => ("sent", None), + Err(e) => ("failed", Some(e.to_string())), + }; + + let _ = chat_channel_message_log_service::create_log( + &db_conn, + cmd.channel_id, + "outbound", + "command_response", + &response.to_plain_text(), + status, + error_detail, + ) + .await; + } + }) +} + +async fn dispatch_command( + text: &str, + db: &DatabaseConnection, + manager: &ChatChannelManager, +) -> super::types::RichMessage { + let parts: Vec<&str> = text.splitn(2, ' ').collect(); + let command = parts[0].to_lowercase(); + let args = parts.get(1).map(|s| s.trim()).unwrap_or(""); + + match command.as_str() { + "/recent" => command_handlers::handle_recent(db).await, + "/search" => { + if args.is_empty() { + super::types::RichMessage::info("用法: /search <关键词>") + .with_title("参数错误") + } else { + command_handlers::handle_search(db, args).await + } + } + "/detail" => { + if let Ok(id) = args.parse::() { + command_handlers::handle_detail(db, id).await + } else { + super::types::RichMessage::info("用法: /detail <会话ID>") + .with_title("参数错误") + } + } + "/today" => command_handlers::handle_today(db).await, + "/status" => command_handlers::handle_status(manager).await, + "/help" | "/start" => command_handlers::handle_help(), + _ => { + if text.starts_with('/') { + super::types::RichMessage::info(format!( + "未知命令: {}\n输入 /help 查看可用命令", + command + )) + .with_title("未知命令") + } else { + // Non-command messages are ignored + return command_handlers::handle_help(); + } + } + } +} diff --git a/src-tauri/src/chat_channel/command_handlers.rs b/src-tauri/src/chat_channel/command_handlers.rs new file mode 100644 index 0000000..ceaaf37 --- /dev/null +++ b/src-tauri/src/chat_channel/command_handlers.rs @@ -0,0 +1,218 @@ +use chrono::Utc; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder}; + +use super::manager::ChatChannelManager; +use super::types::{MessageLevel, RichMessage}; +use crate::db::entities::conversation; + +pub async fn handle_recent(db: &DatabaseConnection) -> RichMessage { + let rows = match conversation::Entity::find() + .filter(conversation::Column::DeletedAt.is_null()) + .order_by_desc(conversation::Column::CreatedAt) + .all(db) + .await + { + Ok(rows) => rows, + Err(e) => { + return RichMessage { + title: Some("查询失败".to_string()), + body: e.to_string(), + fields: Vec::new(), + level: MessageLevel::Error, + }; + } + }; + + let recent: Vec<_> = rows.into_iter().take(5).collect(); + if recent.is_empty() { + return RichMessage::info("暂无会话记录").with_title("最近会话"); + } + + let mut body = String::new(); + for (i, conv) in recent.iter().enumerate() { + let title = conv.title.as_deref().unwrap_or("(无标题)"); + let agent = &conv.agent_type; + let time = conv.created_at.format("%m-%d %H:%M"); + body.push_str(&format!( + "{}. [{}] {} ({})\n", + i + 1, + agent, + title, + time + )); + } + + RichMessage::info(body.trim_end()).with_title("最近 5 条会话") +} + +pub async fn handle_search(db: &DatabaseConnection, keyword: &str) -> RichMessage { + let rows = match conversation::Entity::find() + .filter(conversation::Column::DeletedAt.is_null()) + .order_by_desc(conversation::Column::CreatedAt) + .all(db) + .await + { + Ok(rows) => rows, + Err(e) => { + return RichMessage { + title: Some("查询失败".to_string()), + body: e.to_string(), + fields: Vec::new(), + level: MessageLevel::Error, + }; + } + }; + + let keyword_lower = keyword.to_lowercase(); + let matched: Vec<_> = rows + .into_iter() + .filter(|c| { + c.title + .as_deref() + .map(|t| t.to_lowercase().contains(&keyword_lower)) + .unwrap_or(false) + }) + .take(10) + .collect(); + + if matched.is_empty() { + return RichMessage::info(format!("未找到包含 \"{keyword}\" 的会话")) + .with_title("搜索结果"); + } + + let mut body = String::new(); + for (i, conv) in matched.iter().enumerate() { + let title = conv.title.as_deref().unwrap_or("(无标题)"); + let agent = &conv.agent_type; + body.push_str(&format!("{}. [{}] {} (ID:{})\n", i + 1, agent, title, conv.id)); + } + + RichMessage::info(body.trim_end()) + .with_title(&format!("搜索 \"{}\" - {} 条结果", keyword, matched.len())) +} + +pub async fn handle_detail(db: &DatabaseConnection, conversation_id: i32) -> RichMessage { + let conv = match conversation::Entity::find_by_id(conversation_id) + .filter(conversation::Column::DeletedAt.is_null()) + .one(db) + .await + { + Ok(Some(c)) => c, + Ok(None) => { + return RichMessage::info(format!("会话 {conversation_id} 不存在")) + .with_title("未找到"); + } + Err(e) => { + return RichMessage { + title: Some("查询失败".to_string()), + body: e.to_string(), + fields: Vec::new(), + level: MessageLevel::Error, + }; + } + }; + + let title = conv.title.as_deref().unwrap_or("(无标题)"); + RichMessage::info(title) + .with_title(&format!("会话详情 #{}", conv.id)) + .with_field("代理", &conv.agent_type) + .with_field("状态", format!("{:?}", conv.status)) + .with_field("消息数", &conv.message_count.to_string()) + .with_field("创建时间", &conv.created_at.format("%Y-%m-%d %H:%M").to_string()) +} + +pub async fn handle_today(db: &DatabaseConnection) -> RichMessage { + let now = Utc::now(); + let today_start = now + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc(); + + let rows = match conversation::Entity::find() + .filter(conversation::Column::DeletedAt.is_null()) + .filter(conversation::Column::CreatedAt.gte(today_start)) + .order_by_desc(conversation::Column::CreatedAt) + .all(db) + .await + { + Ok(rows) => rows, + Err(e) => { + return RichMessage { + title: Some("查询失败".to_string()), + body: e.to_string(), + fields: Vec::new(), + level: MessageLevel::Error, + }; + } + }; + + if rows.is_empty() { + return RichMessage::info("今日暂无编码活动").with_title("今日活动"); + } + + // Group by agent_type + let mut by_agent: std::collections::HashMap = std::collections::HashMap::new(); + let mut titles: Vec = Vec::new(); + for conv in &rows { + *by_agent.entry(conv.agent_type.clone()).or_insert(0) += 1; + if let Some(t) = &conv.title { + if titles.len() < 5 { + titles.push(t.clone()); + } + } + } + + let mut body = format!("会话总数: {}", rows.len()); + body.push_str("\n\n按代理:"); + for (agent, count) in &by_agent { + body.push_str(&format!("\n {agent} - {count} 个")); + } + + if !titles.is_empty() { + body.push_str("\n\n最近活动:"); + for t in &titles { + body.push_str(&format!("\n • {t}")); + } + } + + RichMessage::info(body).with_title(&format!( + "今日活动 ({})", + now.format("%Y-%m-%d") + )) +} + +pub async fn handle_status(manager: &ChatChannelManager) -> RichMessage { + let statuses = manager.get_status().await; + if statuses.is_empty() { + return RichMessage::info("暂无活跃渠道").with_title("渠道状态"); + } + + let mut body = String::new(); + for s in &statuses { + let icon = match s.status.as_str() { + "connected" => "●", + "connecting" => "◎", + "error" => "✗", + _ => "○", + }; + body.push_str(&format!( + "{} {} [{}] - {}\n", + icon, s.name, s.channel_type, s.status + )); + } + + RichMessage::info(body.trim_end()).with_title("渠道状态") +} + +pub fn handle_help() -> RichMessage { + RichMessage::info( + "/recent - 最近 5 条会话\n\ + /search <关键词> - 搜索会话\n\ + /detail - 会话详情\n\ + /today - 今日活动汇总\n\ + /status - 渠道连接状态\n\ + /help - 显示帮助", + ) + .with_title("Codeg Bot 帮助") +} diff --git a/src-tauri/src/chat_channel/error.rs b/src-tauri/src/chat_channel/error.rs new file mode 100644 index 0000000..0d6b5e7 --- /dev/null +++ b/src-tauri/src/chat_channel/error.rs @@ -0,0 +1,39 @@ +use crate::app_error::AppCommandError; + +#[derive(Debug, thiserror::Error)] +pub enum ChatChannelError { + #[error("connection failed: {0}")] + ConnectionFailed(String), + #[error("send failed: {0}")] + SendFailed(String), + #[error("authentication failed: {0}")] + AuthenticationFailed(String), + #[error("configuration invalid: {0}")] + ConfigurationInvalid(String), + #[error("not connected")] + NotConnected, + #[error("already connected")] + AlreadyConnected, + #[error("channel not found: {0}")] + NotFound(i32), + #[error("{0}")] + Other(String), +} + +impl From for AppCommandError { + fn from(err: ChatChannelError) -> Self { + match &err { + ChatChannelError::NotFound(_) => AppCommandError::not_found(err.to_string()), + ChatChannelError::AuthenticationFailed(_) => { + AppCommandError::authentication_failed(err.to_string()) + } + ChatChannelError::ConfigurationInvalid(_) => { + AppCommandError::configuration_invalid(err.to_string()) + } + ChatChannelError::ConnectionFailed(_) | ChatChannelError::SendFailed(_) => { + AppCommandError::network(err.to_string()) + } + _ => AppCommandError::task_execution_failed(err.to_string()), + } + } +} diff --git a/src-tauri/src/chat_channel/event_subscriber.rs b/src-tauri/src/chat_channel/event_subscriber.rs new file mode 100644 index 0000000..07942bc --- /dev/null +++ b/src-tauri/src/chat_channel/event_subscriber.rs @@ -0,0 +1,217 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use sea_orm::DatabaseConnection; +use tokio::task::JoinHandle; + +use super::manager::ChatChannelManager; +use super::message_formatter; +use super::types::RichMessage; +use crate::db::service::{chat_channel_message_log_service, chat_channel_service}; +use crate::web::event_bridge::WebEventBroadcaster; + +/// Minimum interval between pushes for the same event type per channel (debounce). +const DEBOUNCE_SECS: u64 = 5; + +pub fn spawn_event_subscriber( + broadcaster: Arc, + manager: ChatChannelManager, + db_conn: DatabaseConnection, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut rx = broadcaster.subscribe(); + let mut last_push: HashMap<(i32, String), Instant> = HashMap::new(); + + loop { + let event = match rx.recv().await { + Ok(e) => e, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + eprintln!("[ChatChannel] event subscriber lagged by {n} messages"); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + eprintln!("[ChatChannel] event broadcaster closed, stopping subscriber"); + break; + } + }; + + let message = match parse_event(&event.channel, &event.payload) { + Some((event_type, msg)) => { + // Check enabled channels and forward + let channels = match chat_channel_service::list_enabled(&db_conn).await { + Ok(c) => c, + Err(e) => { + eprintln!("[ChatChannel] failed to list channels: {e}"); + continue; + } + }; + + for ch in &channels { + // Check event filter + if let Some(filter_json) = &ch.event_filter_json { + if let Ok(filter) = + serde_json::from_str::>(filter_json) + { + if !filter.contains(&event_type) { + continue; + } + } + } + + // Debounce + let key = (ch.id, event_type.clone()); + let now = Instant::now(); + if let Some(last) = last_push.get(&key) { + if now.duration_since(*last) < Duration::from_secs(DEBOUNCE_SECS) { + continue; + } + } + last_push.insert(key, now); + + // Send + let send_result = manager.send_to_channel(ch.id, &msg).await; + let (status, error_detail) = match &send_result { + Ok(_) => ("sent", None), + Err(e) => ("failed", Some(e.to_string())), + }; + + let _ = chat_channel_message_log_service::create_log( + &db_conn, + ch.id, + "outbound", + "event_push", + &msg.to_plain_text(), + status, + error_detail, + ) + .await; + } + + Some(msg) + } + None => None, + }; + + drop(message); + } + }) +} + +fn parse_event(channel: &str, payload: &serde_json::Value) -> Option<(String, RichMessage)> { + match channel { + "acp://event" => parse_acp_event(payload), + "folder://git-push-succeeded" => parse_git_push(payload), + "folder://git-commit-succeeded" => parse_git_commit(payload), + _ => None, + } +} + +fn parse_acp_event(payload: &serde_json::Value) -> Option<(String, RichMessage)> { + let event_type = payload.get("type")?.as_str()?; + let connection_id = payload + .get("connection_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + match event_type { + "session_started" => { + let agent_type = payload + .pointer("/data/agent_type") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown Agent"); + let folder = payload + .pointer("/data/folder_name") + .and_then(|v| v.as_str()) + .unwrap_or(connection_id); + Some(( + "session_started".to_string(), + message_formatter::format_session_started(agent_type, folder), + )) + } + "turn_complete" => { + let stop_reason = payload + .pointer("/data/stop_reason") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + // Only push for end_turn, not for intermediate completions + if stop_reason != "end_turn" { + return None; + } + let agent_type = payload + .pointer("/data/agent_type") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown Agent"); + Some(( + "turn_complete".to_string(), + message_formatter::format_turn_complete(agent_type, stop_reason), + )) + } + "error" => { + let agent_type = payload + .pointer("/data/agent_type") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown Agent"); + let message = payload + .pointer("/data/message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + Some(( + "error".to_string(), + message_formatter::format_agent_error(agent_type, message), + )) + } + "status_changed" => { + let status = payload + .pointer("/data/status") + .and_then(|v| v.as_str())?; + if status != "disconnected" { + return None; + } + let agent_type = payload + .pointer("/data/agent_type") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown Agent"); + Some(( + "status_disconnected".to_string(), + message_formatter::format_agent_disconnected(agent_type), + )) + } + // Phase 2: "permission_request" will be handled here + _ => None, + } +} + +fn parse_git_push(payload: &serde_json::Value) -> Option<(String, RichMessage)> { + let folder_name = payload + .get("folder_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let branch = payload + .get("branch") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let commits = payload + .get("pushed_commits") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + Some(( + "git_push".to_string(), + message_formatter::format_git_push(folder_name, branch, commits), + )) +} + +fn parse_git_commit(payload: &serde_json::Value) -> Option<(String, RichMessage)> { + let folder_name = payload + .get("folder_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let files = payload + .get("committed_files") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as u32; + Some(( + "git_commit".to_string(), + message_formatter::format_git_commit(folder_name, files), + )) +} diff --git a/src-tauri/src/chat_channel/manager.rs b/src-tauri/src/chat_channel/manager.rs new file mode 100644 index 0000000..c0b56ef --- /dev/null +++ b/src-tauri/src/chat_channel/manager.rs @@ -0,0 +1,255 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use sea_orm::DatabaseConnection; +use tokio::sync::{mpsc, Mutex}; + +use super::error::ChatChannelError; +use super::traits::ChatChannelBackend; +use super::types::*; +use crate::web::event_bridge::WebEventBroadcaster; + +struct ActiveChannel { + id: i32, + name: String, + channel_type: ChannelType, + backend: Box, +} + +/// Inner state shared across clones. +struct Inner { + channels: Mutex>, + command_tx: mpsc::Sender, + command_rx: Mutex>>, +} + +pub struct ChatChannelManager { + inner: Arc, +} + +impl ChatChannelManager { + pub fn new() -> Self { + let (command_tx, command_rx) = mpsc::channel(256); + Self { + inner: Arc::new(Inner { + channels: Mutex::new(HashMap::new()), + command_tx, + command_rx: Mutex::new(Some(command_rx)), + }), + } + } + + /// Shallow clone sharing the same state (like ConnectionManager::clone_ref). + pub fn clone_ref(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } + + pub fn command_sender(&self) -> mpsc::Sender { + self.inner.command_tx.clone() + } + + /// Take the command receiver (can only be called once, at startup). + pub async fn take_command_receiver(&self) -> Option> { + self.inner.command_rx.lock().await.take() + } + + pub async fn add_channel( + &self, + id: i32, + name: String, + channel_type: ChannelType, + backend: Box, + ) -> Result<(), ChatChannelError> { + let command_tx = self.inner.command_tx.clone(); + backend.start(command_tx).await?; + + let channel = ActiveChannel { + id, + name, + channel_type, + backend, + }; + + self.inner.channels.lock().await.insert(id, channel); + Ok(()) + } + + pub async fn remove_channel(&self, id: i32) -> Result<(), ChatChannelError> { + let mut channels = self.inner.channels.lock().await; + if let Some(channel) = channels.remove(&id) { + channel.backend.stop().await?; + } + Ok(()) + } + + pub async fn stop_all(&self) { + let mut channels = self.inner.channels.lock().await; + for (_, channel) in channels.drain() { + let _ = channel.backend.stop().await; + } + } + + pub async fn send_to_channel( + &self, + channel_id: i32, + message: &RichMessage, + ) -> Result { + let channels = self.inner.channels.lock().await; + let channel = channels + .get(&channel_id) + .ok_or(ChatChannelError::NotFound(channel_id))?; + channel.backend.send_rich_message(message).await + } + + pub async fn send_to_all(&self, message: &RichMessage) { + let channels = self.inner.channels.lock().await; + for (_, channel) in channels.iter() { + let _ = channel.backend.send_rich_message(message).await; + } + } + + pub async fn get_status(&self) -> Vec { + let channels = self.inner.channels.lock().await; + let mut result = Vec::new(); + for (_, ch) in channels.iter() { + let status = ch.backend.status().await; + result.push(crate::models::ChannelStatusInfo { + channel_id: ch.id, + name: ch.name.clone(), + channel_type: ch.channel_type.to_string(), + status: serde_json::to_value(status) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| "unknown".to_string()), + }); + } + result + } + + pub async fn test_channel(&self, id: i32) -> Result<(), ChatChannelError> { + let channels = self.inner.channels.lock().await; + let channel = channels + .get(&id) + .ok_or(ChatChannelError::NotFound(id))?; + channel.backend.test_connection().await + } + + pub async fn is_connected(&self, id: i32) -> bool { + let channels = self.inner.channels.lock().await; + if let Some(ch) = channels.get(&id) { + ch.backend.status().await == ChannelConnectionStatus::Connected + } else { + false + } + } + + /// Start background tasks (event subscriber + command dispatcher) and + /// auto-connect all enabled channels from DB. + pub async fn start_background( + &self, + broadcaster: Arc, + db_conn: DatabaseConnection, + ) { + let db_conn2 = db_conn.clone(); + + // Spawn event subscriber + let manager_for_events = self.clone_ref(); + super::event_subscriber::spawn_event_subscriber( + broadcaster, + manager_for_events, + db_conn.clone(), + ); + + // Spawn command dispatcher + if let Some(command_rx) = self.take_command_receiver().await { + let manager_for_cmds = self.clone_ref(); + super::command_dispatcher::spawn_command_dispatcher( + command_rx, + manager_for_cmds, + db_conn.clone(), + ); + } + + // Spawn daily report scheduler + let manager_for_scheduler = self.clone_ref(); + super::scheduler::spawn_daily_report_scheduler(manager_for_scheduler, db_conn.clone()); + + // Auto-connect enabled channels + self.auto_connect_channels(&db_conn2).await; + } + + async fn auto_connect_channels(&self, db_conn: &DatabaseConnection) { + let channels = + match crate::db::service::chat_channel_service::list_enabled(db_conn).await { + Ok(c) => c, + Err(e) => { + eprintln!("[ChatChannel] failed to load enabled channels: {e}"); + return; + } + }; + + for ch in channels { + let channel_type: ChannelType = match serde_json::from_value( + serde_json::Value::String(ch.channel_type.clone()), + ) { + Ok(t) => t, + Err(_) => continue, + }; + + let config: serde_json::Value = match serde_json::from_str(&ch.config_json) { + Ok(v) => v, + Err(_) => continue, + }; + + let token = match crate::keyring_store::get_channel_token(ch.id) { + Some(t) => t, + None => continue, + }; + + let backend: Box = match channel_type { + ChannelType::Telegram => { + let chat_id = config + .get("chat_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if chat_id.is_empty() { + continue; + } + Box::new(super::backends::telegram::TelegramBackend::new( + ch.id, token, chat_id, + )) + } + ChannelType::Lark => { + let app_id = config + .get("app_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let chat_id = config + .get("chat_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if app_id.is_empty() || chat_id.is_empty() { + continue; + } + Box::new(super::backends::lark::LarkBackend::new( + ch.id, app_id, token, chat_id, + )) + } + }; + + if let Err(e) = self.add_channel(ch.id, ch.name.clone(), channel_type, backend).await { + eprintln!( + "[ChatChannel] failed to auto-connect '{}' (id={}): {e}", + ch.name, ch.id + ); + } else { + eprintln!("[ChatChannel] auto-connected '{}' (id={})", ch.name, ch.id); + } + } + } +} diff --git a/src-tauri/src/chat_channel/message_formatter.rs b/src-tauri/src/chat_channel/message_formatter.rs new file mode 100644 index 0000000..68fc8e2 --- /dev/null +++ b/src-tauri/src/chat_channel/message_formatter.rs @@ -0,0 +1,86 @@ +use super::types::{MessageLevel, RichMessage}; + +pub fn format_session_started(agent_type: &str, folder_name: &str) -> RichMessage { + RichMessage::info(format!("{agent_type} 在 {folder_name} 开始了新会话")) + .with_title("新会话") +} + +pub fn format_turn_complete(agent_type: &str, stop_reason: &str) -> RichMessage { + let reason = match stop_reason { + "end_turn" => "正常结束", + "cancelled" => "已取消", + _ => stop_reason, + }; + RichMessage::info(format!("{agent_type} 会话已完成")) + .with_title("会话完成") + .with_field("结束原因", reason) +} + +pub fn format_agent_error(agent_type: &str, message: &str) -> RichMessage { + RichMessage { + title: Some("代理错误".to_string()), + body: format!("{agent_type} 发生错误"), + fields: vec![("错误信息".to_string(), message.to_string())], + level: MessageLevel::Error, + } +} + +pub fn format_agent_disconnected(agent_type: &str) -> RichMessage { + RichMessage { + title: Some("代理断开".to_string()), + body: format!("{agent_type} 已断开连接"), + fields: Vec::new(), + level: MessageLevel::Warning, + } +} + +pub fn format_git_push(folder_name: &str, branch: &str, commits: u32) -> RichMessage { + RichMessage::info(format!( + "Git Push 成功: {commits} 个提交推送到 {branch}" + )) + .with_title("Git Push") + .with_field("项目", folder_name) +} + +pub fn format_git_commit(folder_name: &str, files: u32) -> RichMessage { + RichMessage::info(format!("Git Commit: {files} 个文件已提交")) + .with_title("Git Commit") + .with_field("项目", folder_name) +} + +pub struct DailyReportData { + pub date: String, + pub conversations_by_agent: Vec<(String, u32)>, + pub total_conversations: u32, + pub projects_involved: Vec, + pub key_activities: Vec, +} + +pub fn format_daily_report(report: &DailyReportData) -> RichMessage { + let mut body = format!("今日编码活动汇总 ({})", report.date); + + body.push_str(&format!("\n\n会话总数: {}", report.total_conversations)); + + if !report.conversations_by_agent.is_empty() { + body.push_str("\n\n按代理分布:"); + for (agent, count) in &report.conversations_by_agent { + body.push_str(&format!("\n {} - {} 个会话", agent, count)); + } + } + + if !report.projects_involved.is_empty() { + body.push_str(&format!( + "\n\n涉及项目: {}", + report.projects_involved.join(", ") + )); + } + + if !report.key_activities.is_empty() { + body.push_str("\n\n主要活动:"); + for activity in &report.key_activities { + body.push_str(&format!("\n • {}", activity)); + } + } + + RichMessage::info(body).with_title("每日编码报告") +} diff --git a/src-tauri/src/chat_channel/mod.rs b/src-tauri/src/chat_channel/mod.rs new file mode 100644 index 0000000..f0862a6 --- /dev/null +++ b/src-tauri/src/chat_channel/mod.rs @@ -0,0 +1,10 @@ +pub mod backends; +pub mod command_dispatcher; +pub mod command_handlers; +pub mod error; +pub mod event_subscriber; +pub mod manager; +pub mod message_formatter; +pub mod scheduler; +pub mod traits; +pub mod types; diff --git a/src-tauri/src/chat_channel/scheduler.rs b/src-tauri/src/chat_channel/scheduler.rs new file mode 100644 index 0000000..00093a9 --- /dev/null +++ b/src-tauri/src/chat_channel/scheduler.rs @@ -0,0 +1,130 @@ +use std::collections::HashSet; + +use chrono::{Local, NaiveDate, Timelike, Utc}; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder}; +use tokio::task::JoinHandle; + +use super::manager::ChatChannelManager; +use super::message_formatter::{self, DailyReportData}; +use crate::db::entities::conversation; +use crate::db::service::{chat_channel_message_log_service, chat_channel_service}; + +pub fn spawn_daily_report_scheduler( + manager: ChatChannelManager, + db_conn: DatabaseConnection, +) -> JoinHandle<()> { + tokio::spawn(async move { + let mut sent_today: HashSet<(i32, NaiveDate)> = HashSet::new(); + + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; + + let now = Local::now(); + let today = now.date_naive(); + let current_time = format!("{:02}:{:02}", now.hour(), now.minute()); + + // Clean up old entries from sent_today + sent_today.retain(|(_, date)| *date == today); + + let channels = match chat_channel_service::list_enabled(&db_conn).await { + Ok(c) => c, + Err(e) => { + eprintln!("[ChatChannel] scheduler: failed to list channels: {e}"); + continue; + } + }; + + for ch in &channels { + if !ch.daily_report_enabled { + continue; + } + + let report_time = ch + .daily_report_time + .as_deref() + .unwrap_or("18:00"); + + if current_time != report_time { + continue; + } + + let key = (ch.id, today); + if sent_today.contains(&key) { + continue; + } + + // Generate and send report + let report = generate_daily_report(&db_conn).await; + let message = message_formatter::format_daily_report(&report); + + let send_result = manager.send_to_channel(ch.id, &message).await; + let (status, error_detail) = match &send_result { + Ok(_) => ("sent", None), + Err(e) => ("failed", Some(e.to_string())), + }; + + let _ = chat_channel_message_log_service::create_log( + &db_conn, + ch.id, + "outbound", + "daily_report", + &message.to_plain_text(), + status, + error_detail, + ) + .await; + + sent_today.insert(key); + } + } + }) +} + +async fn generate_daily_report(db: &DatabaseConnection) -> DailyReportData { + let now = Utc::now(); + let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); + + let rows = conversation::Entity::find() + .filter(conversation::Column::DeletedAt.is_null()) + .filter(conversation::Column::CreatedAt.gte(today_start)) + .order_by_desc(conversation::Column::CreatedAt) + .all(db) + .await + .unwrap_or_default(); + + let mut by_agent: std::collections::HashMap = + std::collections::HashMap::new(); + let mut folder_ids: HashSet = HashSet::new(); + let mut activities: Vec = Vec::new(); + + for conv in &rows { + *by_agent.entry(conv.agent_type.clone()).or_insert(0) += 1; + folder_ids.insert(conv.folder_id); + if let Some(title) = &conv.title { + if activities.len() < 10 { + activities.push(title.clone()); + } + } + } + + // Resolve folder names + let mut project_names: Vec = Vec::new(); + for fid in &folder_ids { + if let Ok(Some(folder)) = crate::db::entities::folder::Entity::find_by_id(*fid) + .one(db) + .await + { + project_names.push(folder.name); + } + } + + let conversations_by_agent: Vec<(String, u32)> = by_agent.into_iter().collect(); + + DailyReportData { + date: now.format("%Y-%m-%d").to_string(), + total_conversations: rows.len() as u32, + conversations_by_agent, + projects_involved: project_names, + key_activities: activities, + } +} diff --git a/src-tauri/src/chat_channel/traits.rs b/src-tauri/src/chat_channel/traits.rs new file mode 100644 index 0000000..4ea5e83 --- /dev/null +++ b/src-tauri/src/chat_channel/traits.rs @@ -0,0 +1,53 @@ +use async_trait::async_trait; +use tokio::sync::mpsc; + +use super::error::ChatChannelError; +use super::types::*; + +#[async_trait] +pub trait ChatChannelBackend: Send + Sync + 'static { + fn channel_type(&self) -> ChannelType; + + /// Start the receiving loop. `command_tx` forwards incoming IM messages + /// to the central command dispatcher. + async fn start( + &self, + command_tx: mpsc::Sender, + ) -> Result<(), ChatChannelError>; + + /// Stop the backend connection gracefully. + async fn stop(&self) -> Result<(), ChatChannelError>; + + /// Current connection status. + async fn status(&self) -> ChannelConnectionStatus; + + /// Send a plain text message. + async fn send_message(&self, text: &str) -> Result; + + /// Send a rich/structured message (Telegram Markdown / Lark Card). + async fn send_rich_message( + &self, + message: &RichMessage, + ) -> Result; + + /// [Phase 2] Send an interactive message with action buttons. + /// Default implementation degrades to send_rich_message. + async fn send_interactive_message( + &self, + message: &InteractiveMessage, + ) -> Result { + self.send_rich_message(&message.to_rich_fallback()).await + } + + /// [Phase 2] Update an already-sent message (e.g., permission status change). + async fn update_message( + &self, + _message_id: &SentMessageId, + _message: &RichMessage, + ) -> Result<(), ChatChannelError> { + Ok(()) + } + + /// Test the connection (used by "Test Connection" button in UI). + async fn test_connection(&self) -> Result<(), ChatChannelError>; +} diff --git a/src-tauri/src/chat_channel/types.rs b/src-tauri/src/chat_channel/types.rs new file mode 100644 index 0000000..263468f --- /dev/null +++ b/src-tauri/src/chat_channel/types.rs @@ -0,0 +1,125 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChannelType { + Lark, + Telegram, +} + +impl std::fmt::Display for ChannelType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ChannelType::Lark => write!(f, "lark"), + ChannelType::Telegram => write!(f, "telegram"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChannelConnectionStatus { + Connected, + Connecting, + Disconnected, + Error, +} + +#[derive(Debug, Clone)] +pub struct SentMessageId(pub String); + +pub struct IncomingCommand { + pub channel_id: i32, + pub sender_id: String, + pub command_text: String, + pub metadata: serde_json::Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MessageLevel { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone)] +pub struct RichMessage { + pub title: Option, + pub body: String, + pub fields: Vec<(String, String)>, + pub level: MessageLevel, +} + +impl RichMessage { + pub fn info(body: impl Into) -> Self { + Self { + title: None, + body: body.into(), + fields: Vec::new(), + level: MessageLevel::Info, + } + } + + pub fn with_title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + pub fn with_field(mut self, key: impl Into, value: impl Into) -> Self { + self.fields.push((key.into(), value.into())); + self + } + + pub fn to_plain_text(&self) -> String { + let mut text = String::new(); + if let Some(title) = &self.title { + text.push_str(title); + text.push('\n'); + } + text.push_str(&self.body); + for (key, value) in &self.fields { + text.push_str(&format!("\n{}: {}", key, value)); + } + text + } +} + +// ── Phase 2 forward-compatible types ── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ButtonStyle { + Primary, + Danger, + Default, +} + +#[derive(Debug, Clone)] +pub struct MessageButton { + pub id: String, + pub label: String, + pub style: ButtonStyle, +} + +#[derive(Debug, Clone)] +pub struct InteractiveMessage { + pub base: RichMessage, + pub buttons: Vec, + pub callback_context: serde_json::Value, +} + +impl InteractiveMessage { + pub fn to_rich_fallback(&self) -> RichMessage { + let mut msg = self.base.clone(); + if !self.buttons.is_empty() { + let button_text: Vec = self + .buttons + .iter() + .map(|b| format!("[{}]", b.label)) + .collect(); + msg.body.push_str(&format!("\n\n{}", button_text.join(" "))); + } + msg + } +} diff --git a/src-tauri/src/commands/chat_channel.rs b/src-tauri/src/commands/chat_channel.rs new file mode 100644 index 0000000..0958239 --- /dev/null +++ b/src-tauri/src/commands/chat_channel.rs @@ -0,0 +1,380 @@ +use crate::app_error::AppCommandError; +use crate::chat_channel::backends::lark::LarkBackend; +use crate::chat_channel::backends::telegram::TelegramBackend; +use crate::chat_channel::manager::ChatChannelManager; +use crate::chat_channel::traits::ChatChannelBackend; +use crate::chat_channel::types::ChannelType; +use crate::db::service::{chat_channel_message_log_service, chat_channel_service}; +use crate::db::AppDatabase; +use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; + +// --------------------------------------------------------------------------- +// Shared core functions (used by both Tauri commands and web handlers) +// --------------------------------------------------------------------------- + +pub async fn list_chat_channels_core( + db: &AppDatabase, +) -> Result, AppCommandError> { + let rows = chat_channel_service::list_all(&db.conn) + .await + .map_err(AppCommandError::from)?; + Ok(rows.into_iter().map(ChatChannelInfo::from).collect()) +} + +pub async fn create_chat_channel_core( + db: &AppDatabase, + name: String, + channel_type: String, + config_json: String, + enabled: bool, + daily_report_enabled: bool, + daily_report_time: Option, +) -> Result { + // Validate channel_type + let _: ChannelType = serde_json::from_value(serde_json::Value::String(channel_type.clone())) + .map_err(|_| { + AppCommandError::invalid_input(format!("Invalid channel type: {channel_type}")) + })?; + + let model = chat_channel_service::create( + &db.conn, + name, + channel_type, + config_json, + enabled, + daily_report_enabled, + daily_report_time, + ) + .await + .map_err(AppCommandError::from)?; + Ok(ChatChannelInfo::from(model)) +} + +pub async fn update_chat_channel_core( + db: &AppDatabase, + id: i32, + name: Option, + enabled: Option, + config_json: Option, + event_filter_json: Option>, + daily_report_enabled: Option, + daily_report_time: Option>, +) -> Result { + let model = chat_channel_service::update( + &db.conn, + id, + name, + enabled, + config_json, + event_filter_json, + daily_report_enabled, + daily_report_time, + ) + .await + .map_err(AppCommandError::from)?; + Ok(ChatChannelInfo::from(model)) +} + +pub async fn delete_chat_channel_core(db: &AppDatabase, id: i32) -> Result<(), AppCommandError> { + chat_channel_service::delete(&db.conn, id) + .await + .map_err(AppCommandError::from)?; + let _ = crate::keyring_store::delete_channel_token(id); + Ok(()) +} + +pub async fn connect_chat_channel_core( + db: &AppDatabase, + manager: &ChatChannelManager, + id: i32, +) -> Result<(), AppCommandError> { + let model = chat_channel_service::get_by_id(&db.conn, id) + .await + .map_err(AppCommandError::from)? + .ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?; + + let channel_type: ChannelType = + serde_json::from_value(serde_json::Value::String(model.channel_type.clone())) + .map_err(|_| { + AppCommandError::configuration_invalid(format!( + "Invalid channel type: {}", + model.channel_type + )) + })?; + + let backend: Box = match channel_type { + ChannelType::Telegram => { + let config: serde_json::Value = + serde_json::from_str(&model.config_json).map_err(|e| { + AppCommandError::configuration_invalid("Invalid config JSON") + .with_detail(e.to_string()) + })?; + let chat_id = config + .get("chat_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppCommandError::configuration_missing("chat_id is required"))? + .to_string(); + let bot_token = crate::keyring_store::get_channel_token(id).ok_or_else(|| { + AppCommandError::configuration_missing("Bot token not set") + })?; + Box::new(TelegramBackend::new(id, bot_token, chat_id)) + } + ChannelType::Lark => { + let config: serde_json::Value = + serde_json::from_str(&model.config_json).map_err(|e| { + AppCommandError::configuration_invalid("Invalid config JSON") + .with_detail(e.to_string()) + })?; + let app_id = config + .get("app_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppCommandError::configuration_missing("app_id is required"))? + .to_string(); + let chat_id = config + .get("chat_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppCommandError::configuration_missing("chat_id is required"))? + .to_string(); + let app_secret = crate::keyring_store::get_channel_token(id).ok_or_else(|| { + AppCommandError::configuration_missing("App Secret not set") + })?; + Box::new(LarkBackend::new(id, app_id, app_secret, chat_id)) + } + }; + + manager + .add_channel(id, model.name, channel_type, backend) + .await + .map_err(AppCommandError::from)?; + + Ok(()) +} + +pub async fn test_chat_channel_core( + db: &AppDatabase, + id: i32, +) -> Result<(), AppCommandError> { + let model = chat_channel_service::get_by_id(&db.conn, id) + .await + .map_err(AppCommandError::from)? + .ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?; + + let channel_type: ChannelType = + serde_json::from_value(serde_json::Value::String(model.channel_type.clone())) + .map_err(|_| { + AppCommandError::configuration_invalid(format!( + "Invalid channel type: {}", + model.channel_type + )) + })?; + + match channel_type { + ChannelType::Telegram => { + let config: serde_json::Value = + serde_json::from_str(&model.config_json).map_err(|e| { + AppCommandError::configuration_invalid("Invalid config JSON") + .with_detail(e.to_string()) + })?; + let chat_id = config + .get("chat_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppCommandError::configuration_missing("chat_id is required"))? + .to_string(); + let bot_token = crate::keyring_store::get_channel_token(id).ok_or_else(|| { + AppCommandError::configuration_missing("Bot token not set") + })?; + let backend = TelegramBackend::new(id, bot_token, chat_id); + backend + .test_connection() + .await + .map_err(AppCommandError::from)?; + } + ChannelType::Lark => { + let config: serde_json::Value = + serde_json::from_str(&model.config_json).map_err(|e| { + AppCommandError::configuration_invalid("Invalid config JSON") + .with_detail(e.to_string()) + })?; + let app_id = config + .get("app_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppCommandError::configuration_missing("app_id is required"))? + .to_string(); + let chat_id = config + .get("chat_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppCommandError::configuration_missing("chat_id is required"))? + .to_string(); + let app_secret = crate::keyring_store::get_channel_token(id).ok_or_else(|| { + AppCommandError::configuration_missing("App Secret not set") + })?; + let backend = LarkBackend::new(id, app_id, app_secret, chat_id); + backend + .test_connection() + .await + .map_err(AppCommandError::from)?; + } + } + + Ok(()) +} + +pub fn save_chat_channel_token_core(channel_id: i32, token: &str) -> Result<(), AppCommandError> { + crate::keyring_store::set_channel_token(channel_id, token) + .map_err(|e| AppCommandError::io_error("Failed to save token").with_detail(e)) +} + +pub fn get_chat_channel_has_token_core(channel_id: i32) -> Result { + Ok(crate::keyring_store::get_channel_token(channel_id).is_some()) +} + +pub fn delete_chat_channel_token_core(channel_id: i32) -> Result<(), AppCommandError> { + crate::keyring_store::delete_channel_token(channel_id) + .map_err(|e| AppCommandError::io_error("Failed to delete token").with_detail(e)) +} + +pub async fn disconnect_chat_channel_core( + manager: &ChatChannelManager, + id: i32, +) -> Result<(), AppCommandError> { + manager + .remove_channel(id) + .await + .map_err(AppCommandError::from)?; + Ok(()) +} + +pub async fn get_chat_channel_status_core( + manager: &ChatChannelManager, +) -> Result, AppCommandError> { + Ok(manager.get_status().await) +} + +pub async fn list_chat_channel_messages_core( + db: &AppDatabase, + channel_id: i32, + limit: Option, + offset: Option, +) -> Result, AppCommandError> { + let limit = limit.unwrap_or(50); + let offset = offset.unwrap_or(0); + let rows = chat_channel_message_log_service::list_by_channel(&db.conn, channel_id, limit, offset) + .await + .map_err(AppCommandError::from)?; + Ok(rows.into_iter().map(ChatChannelMessageLogInfo::from).collect()) +} + +// --------------------------------------------------------------------------- +// Tauri commands (use tauri::State for injection) +// --------------------------------------------------------------------------- + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn list_chat_channels( + db: tauri::State<'_, AppDatabase>, +) -> Result, AppCommandError> { + list_chat_channels_core(&db).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn create_chat_channel( + db: tauri::State<'_, AppDatabase>, + name: String, + channel_type: String, + config_json: String, + enabled: bool, + daily_report_enabled: bool, + daily_report_time: Option, +) -> Result { + create_chat_channel_core(&db, name, channel_type, config_json, enabled, daily_report_enabled, daily_report_time).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn update_chat_channel( + db: tauri::State<'_, AppDatabase>, + id: i32, + name: Option, + enabled: Option, + config_json: Option, + event_filter_json: Option>, + daily_report_enabled: Option, + daily_report_time: Option>, +) -> Result { + update_chat_channel_core(&db, id, name, enabled, config_json, event_filter_json, daily_report_enabled, daily_report_time).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn delete_chat_channel( + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + delete_chat_channel_core(&db, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn save_chat_channel_token(channel_id: i32, token: String) -> Result<(), AppCommandError> { + save_chat_channel_token_core(channel_id, &token) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn get_chat_channel_has_token(channel_id: i32) -> Result { + get_chat_channel_has_token_core(channel_id) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn delete_chat_channel_token(channel_id: i32) -> Result<(), AppCommandError> { + delete_chat_channel_token_core(channel_id) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn connect_chat_channel( + db: tauri::State<'_, AppDatabase>, + manager: tauri::State<'_, ChatChannelManager>, + id: i32, +) -> Result<(), AppCommandError> { + connect_chat_channel_core(&db, &manager, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn disconnect_chat_channel( + manager: tauri::State<'_, ChatChannelManager>, + id: i32, +) -> Result<(), AppCommandError> { + disconnect_chat_channel_core(&manager, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn test_chat_channel( + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + test_chat_channel_core(&db, id).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn get_chat_channel_status( + manager: tauri::State<'_, ChatChannelManager>, +) -> Result, AppCommandError> { + get_chat_channel_status_core(&manager).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn list_chat_channel_messages( + db: tauri::State<'_, AppDatabase>, + channel_id: i32, + limit: Option, + offset: Option, +) -> Result, AppCommandError> { + list_chat_channel_messages_core(&db, channel_id, limit, offset).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 566e480..b1c051c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod acp; +pub mod chat_channel; pub mod conversations; pub mod folder_commands; pub mod folders; diff --git a/src-tauri/src/db/entities/chat_channel.rs b/src-tauri/src/db/entities/chat_channel.rs new file mode 100644 index 0000000..1162d74 --- /dev/null +++ b/src-tauri/src/db/entities/chat_channel.rs @@ -0,0 +1,31 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "chat_channel")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + pub channel_type: String, + pub enabled: bool, + pub config_json: String, + pub event_filter_json: Option, + pub daily_report_enabled: bool, + pub daily_report_time: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::chat_channel_message_log::Entity")] + MessageLogs, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MessageLogs.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/chat_channel_message_log.rs b/src-tauri/src/db/entities/chat_channel_message_log.rs new file mode 100644 index 0000000..1a351c7 --- /dev/null +++ b/src-tauri/src/db/entities/chat_channel_message_log.rs @@ -0,0 +1,33 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "chat_channel_message_log")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub channel_id: i32, + pub direction: String, + pub message_type: String, + pub content_preview: String, + pub status: String, + pub error_detail: Option, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::chat_channel::Entity", + from = "Column::ChannelId", + to = "super::chat_channel::Column::Id" + )] + ChatChannel, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChatChannel.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index dd261f9..b826120 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -1,5 +1,7 @@ pub mod agent_setting; pub mod app_metadata; +pub mod chat_channel; +pub mod chat_channel_message_log; pub mod conversation; pub mod folder; pub mod folder_command; diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index 243a2f2..a8a775d 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -2,6 +2,8 @@ pub use super::agent_setting::Entity as AgentSetting; pub use super::app_metadata::Entity as AppMetadata; +pub use super::chat_channel::Entity as ChatChannel; +pub use super::chat_channel_message_log::Entity as ChatChannelMessageLog; pub use super::conversation::Entity as Conversation; pub use super::folder::Entity as Folder; pub use super::folder_command::Entity as FolderCommand; diff --git a/src-tauri/src/db/migration/m20260330_000001_chat_channel.rs b/src-tauri/src/db/migration/m20260330_000001_chat_channel.rs new file mode 100644 index 0000000..28f9bce --- /dev/null +++ b/src-tauri/src/db/migration/m20260330_000001_chat_channel.rs @@ -0,0 +1,179 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // ─── chat_channel ─── + manager + .create_table( + Table::create() + .table(ChatChannel::Table) + .if_not_exists() + .col( + ColumnDef::new(ChatChannel::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(ChatChannel::Name).string().not_null()) + .col( + ColumnDef::new(ChatChannel::ChannelType) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ChatChannel::Enabled) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(ChatChannel::ConfigJson) + .text() + .not_null(), + ) + .col(ColumnDef::new(ChatChannel::EventFilterJson).text().null()) + .col( + ColumnDef::new(ChatChannel::DailyReportEnabled) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(ChatChannel::DailyReportTime) + .string() + .null(), + ) + .col( + ColumnDef::new(ChatChannel::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(ChatChannel::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + // ─── chat_channel_message_log ─── + manager + .create_table( + Table::create() + .table(ChatChannelMessageLog::Table) + .if_not_exists() + .col( + ColumnDef::new(ChatChannelMessageLog::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(ChatChannelMessageLog::ChannelId) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(ChatChannelMessageLog::Direction) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ChatChannelMessageLog::MessageType) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ChatChannelMessageLog::ContentPreview) + .text() + .not_null(), + ) + .col( + ColumnDef::new(ChatChannelMessageLog::Status) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ChatChannelMessageLog::ErrorDetail) + .text() + .null(), + ) + .col( + ColumnDef::new(ChatChannelMessageLog::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name("fk_ccml_channel_id") + .from( + ChatChannelMessageLog::Table, + ChatChannelMessageLog::ChannelId, + ) + .to(ChatChannel::Table, ChatChannel::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_ccml_channel_created") + .table(ChatChannelMessageLog::Table) + .col(ChatChannelMessageLog::ChannelId) + .col(ChatChannelMessageLog::CreatedAt) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(ChatChannelMessageLog::Table) + .to_owned(), + ) + .await?; + manager + .drop_table(Table::drop().table(ChatChannel::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum ChatChannel { + Table, + Id, + Name, + ChannelType, + Enabled, + ConfigJson, + EventFilterJson, + DailyReportEnabled, + DailyReportTime, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum ChatChannelMessageLog { + Table, + Id, + ChannelId, + Direction, + MessageType, + ContentPreview, + Status, + ErrorDetail, + CreatedAt, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 351c725..6e2fa02 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -5,6 +5,7 @@ mod m20260219_000001_folder_command; mod m20260221_000001_folder_is_open; mod m20260226_000001_agent_setting; mod m20260227_000001_folder_parent_branch; +mod m20260330_000001_chat_channel; pub struct Migrator; #[async_trait::async_trait] @@ -16,6 +17,7 @@ impl MigratorTrait for Migrator { Box::new(m20260221_000001_folder_is_open::Migration), Box::new(m20260226_000001_agent_setting::Migration), Box::new(m20260227_000001_folder_parent_branch::Migration), + Box::new(m20260330_000001_chat_channel::Migration), ] } } diff --git a/src-tauri/src/db/service/chat_channel_message_log_service.rs b/src-tauri/src/db/service/chat_channel_message_log_service.rs new file mode 100644 index 0000000..78331fd --- /dev/null +++ b/src-tauri/src/db/service/chat_channel_message_log_service.rs @@ -0,0 +1,70 @@ +use chrono::Utc; +use sea_orm::prelude::DateTimeUtc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait, + QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::chat_channel_message_log; +use crate::db::error::DbError; + +pub async fn create_log( + conn: &DatabaseConnection, + channel_id: i32, + direction: &str, + message_type: &str, + content_preview: &str, + status: &str, + error_detail: Option, +) -> Result<(), DbError> { + let active = chat_channel_message_log::ActiveModel { + id: NotSet, + channel_id: Set(channel_id), + direction: Set(direction.to_string()), + message_type: Set(message_type.to_string()), + content_preview: Set(truncate_preview(content_preview)), + status: Set(status.to_string()), + error_detail: Set(error_detail), + created_at: Set(Utc::now()), + }; + active.insert(conn).await?; + Ok(()) +} + +pub async fn list_by_channel( + conn: &DatabaseConnection, + channel_id: i32, + limit: u64, + offset: u64, +) -> Result, DbError> { + use sea_orm::PaginatorTrait; + Ok(chat_channel_message_log::Entity::find() + .filter(chat_channel_message_log::Column::ChannelId.eq(channel_id)) + .order_by_desc(chat_channel_message_log::Column::CreatedAt) + .paginate(conn, limit) + .fetch_page(offset / limit) + .await?) +} + +pub async fn cleanup_old_logs( + conn: &DatabaseConnection, + older_than: DateTimeUtc, +) -> Result { + let result = chat_channel_message_log::Entity::delete_many() + .filter(chat_channel_message_log::Column::CreatedAt.lt(older_than)) + .exec(conn) + .await?; + Ok(result.rows_affected) +} + +fn truncate_preview(s: &str) -> String { + if s.len() <= 200 { + s.to_string() + } else { + let mut end = 200; + while !s.is_char_boundary(end) && end > 0 { + end -= 1; + } + format!("{}...", &s[..end]) + } +} diff --git a/src-tauri/src/db/service/chat_channel_service.rs b/src-tauri/src/db/service/chat_channel_service.rs new file mode 100644 index 0000000..db6bffc --- /dev/null +++ b/src-tauri/src/db/service/chat_channel_service.rs @@ -0,0 +1,98 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait, + IntoActiveModel, QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::chat_channel; +use crate::db::error::DbError; + +pub async fn create( + conn: &DatabaseConnection, + name: String, + channel_type: String, + config_json: String, + enabled: bool, + daily_report_enabled: bool, + daily_report_time: Option, +) -> Result { + let now = Utc::now(); + let active = chat_channel::ActiveModel { + id: NotSet, + name: Set(name), + channel_type: Set(channel_type), + enabled: Set(enabled), + config_json: Set(config_json), + event_filter_json: Set(None), + daily_report_enabled: Set(daily_report_enabled), + daily_report_time: Set(daily_report_time), + created_at: Set(now), + updated_at: Set(now), + }; + Ok(active.insert(conn).await?) +} + +pub async fn update( + conn: &DatabaseConnection, + id: i32, + name: Option, + enabled: Option, + config_json: Option, + event_filter_json: Option>, + daily_report_enabled: Option, + daily_report_time: Option>, +) -> Result { + let model = chat_channel::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::Migration(format!("chat channel not found: {id}")))?; + + let mut active = model.into_active_model(); + if let Some(v) = name { + active.name = Set(v); + } + if let Some(v) = enabled { + active.enabled = Set(v); + } + if let Some(v) = config_json { + active.config_json = Set(v); + } + if let Some(v) = event_filter_json { + active.event_filter_json = Set(v); + } + if let Some(v) = daily_report_enabled { + active.daily_report_enabled = Set(v); + } + if let Some(v) = daily_report_time { + active.daily_report_time = Set(v); + } + active.updated_at = Set(Utc::now()); + Ok(active.update(conn).await?) +} + +pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> { + chat_channel::Entity::delete_by_id(id).exec(conn).await?; + Ok(()) +} + +pub async fn get_by_id( + conn: &DatabaseConnection, + id: i32, +) -> Result, DbError> { + Ok(chat_channel::Entity::find_by_id(id).one(conn).await?) +} + +pub async fn list_all(conn: &DatabaseConnection) -> Result, DbError> { + Ok(chat_channel::Entity::find() + .order_by_asc(chat_channel::Column::Id) + .all(conn) + .await?) +} + +pub async fn list_enabled(conn: &DatabaseConnection) -> Result, DbError> { + Ok(chat_channel::Entity::find() + .filter(chat_channel::Column::Enabled.eq(true)) + .order_by_asc(chat_channel::Column::Id) + .all(conn) + .await?) +} diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index efdd6b9..97949ec 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -1,5 +1,7 @@ pub mod agent_setting_service; pub mod app_metadata_service; +pub mod chat_channel_message_log_service; +pub mod chat_channel_service; pub mod conversation_service; pub mod folder_command_service; pub mod folder_service; diff --git a/src-tauri/src/keyring_store.rs b/src-tauri/src/keyring_store.rs index 1fe1874..e5f90e4 100644 --- a/src-tauri/src/keyring_store.rs +++ b/src-tauri/src/keyring_store.rs @@ -5,6 +5,10 @@ fn token_key(account_id: &str) -> String { format!("github-token:{}", account_id) } +fn channel_token_key(channel_id: i32) -> String { + format!("chat-channel:{}", channel_id) +} + // ── Tauri mode: OS keyring ── #[cfg(feature = "tauri-runtime")] @@ -87,3 +91,51 @@ pub fn delete_token(account_id: &str) -> Result<(), String> { tokens.remove(&token_key(account_id)); write_tokens(&tokens) } + +// ── Chat channel token helpers ── +// Reuse the same storage mechanism (keyring or file) with a different key prefix. + +#[cfg(feature = "tauri-runtime")] +pub fn set_channel_token(channel_id: i32, token: &str) -> Result<(), String> { + let entry = keyring::Entry::new(SERVICE_NAME, &channel_token_key(channel_id)) + .map_err(|e| format!("keyring init error: {e}"))?; + entry + .set_password(token) + .map_err(|e| format!("keyring set error: {e}")) +} + +#[cfg(feature = "tauri-runtime")] +pub fn get_channel_token(channel_id: i32) -> Option { + let entry = keyring::Entry::new(SERVICE_NAME, &channel_token_key(channel_id)).ok()?; + entry.get_password().ok() +} + +#[cfg(feature = "tauri-runtime")] +pub fn delete_channel_token(channel_id: i32) -> Result<(), String> { + let entry = keyring::Entry::new(SERVICE_NAME, &channel_token_key(channel_id)) + .map_err(|e| format!("keyring init error: {e}"))?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("keyring delete error: {e}")), + } +} + +#[cfg(not(feature = "tauri-runtime"))] +pub fn set_channel_token(channel_id: i32, token: &str) -> Result<(), String> { + let mut tokens = read_tokens(); + tokens.insert(channel_token_key(channel_id), token.to_string()); + write_tokens(&tokens) +} + +#[cfg(not(feature = "tauri-runtime"))] +pub fn get_channel_token(channel_id: i32) -> Option { + read_tokens().get(&channel_token_key(channel_id)).cloned() +} + +#[cfg(not(feature = "tauri-runtime"))] +pub fn delete_channel_token(channel_id: i32) -> Result<(), String> { + let mut tokens = read_tokens(); + tokens.remove(&channel_token_key(channel_id)); + write_tokens(&tokens) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7640e58..d7d4f0b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod acp; mod app_error; pub mod app_state; +pub mod chat_channel; mod commands; pub mod db; pub mod git_credential; @@ -17,10 +18,11 @@ mod tauri_app { use std::sync::atomic::{AtomicBool, Ordering}; use crate::acp::manager::ConnectionManager; + use crate::chat_channel::manager::ChatChannelManager; use crate::commands::{ - acp as acp_commands, conversations, folder_commands, folders, mcp as mcp_commands, - notification, project_boot, system_settings, terminal as terminal_commands, version_control, - windows, + acp as acp_commands, chat_channel as chat_channel_commands, conversations, folder_commands, + folders, mcp as mcp_commands, notification, project_boot, system_settings, + terminal as terminal_commands, version_control, windows, }; use crate::terminal::manager::TerminalManager; use crate::{db, network, process, web}; @@ -52,6 +54,7 @@ mod tauri_app { .plugin(tauri_plugin_notification::init()) .manage(ConnectionManager::new()) .manage(TerminalManager::new()) + .manage(ChatChannelManager::new()) .manage(windows::SettingsWindowState::new()) .manage(windows::CommitWindowState::new()) .manage(windows::MergeWindowState::new()) @@ -78,6 +81,19 @@ mod tauri_app { } } + // Start chat channel background tasks + { + let ccm = app.state::(); + let broadcaster = app + .state::>(); + let db_conn = app.state::().conn.clone(); + let ccm_ref = ccm.clone_ref(); + let br = broadcaster.inner().clone(); + tauri::async_runtime::spawn(async move { + ccm_ref.start_background(br, db_conn).await; + }); + } + // Restore previously open folders or show welcome let db = app.state::(); let open_folders = tauri::async_runtime::block_on( @@ -351,6 +367,18 @@ mod tauri_app { mcp_commands::mcp_set_server_apps, mcp_commands::mcp_remove_server, notification::send_notification, + chat_channel_commands::list_chat_channels, + chat_channel_commands::create_chat_channel, + chat_channel_commands::update_chat_channel, + chat_channel_commands::delete_chat_channel, + chat_channel_commands::save_chat_channel_token, + chat_channel_commands::get_chat_channel_has_token, + chat_channel_commands::delete_chat_channel_token, + chat_channel_commands::connect_chat_channel, + chat_channel_commands::disconnect_chat_channel, + chat_channel_commands::test_chat_channel, + chat_channel_commands::get_chat_channel_status, + chat_channel_commands::list_chat_channel_messages, web::start_web_server, web::stop_web_server, web::get_web_server_status, diff --git a/src-tauri/src/models/chat_channel.rs b/src-tauri/src/models/chat_channel.rs new file mode 100644 index 0000000..a73924c --- /dev/null +++ b/src-tauri/src/models/chat_channel.rs @@ -0,0 +1,67 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatChannelInfo { + pub id: i32, + pub name: String, + pub channel_type: String, + pub enabled: bool, + pub config_json: String, + pub event_filter_json: Option, + pub daily_report_enabled: bool, + pub daily_report_time: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelStatusInfo { + pub channel_id: i32, + pub name: String, + pub channel_type: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatChannelMessageLogInfo { + pub id: i32, + pub channel_id: i32, + pub direction: String, + pub message_type: String, + pub content_preview: String, + pub status: String, + pub error_detail: Option, + pub created_at: String, +} + +impl From for ChatChannelInfo { + fn from(m: crate::db::entities::chat_channel::Model) -> Self { + Self { + id: m.id, + name: m.name, + channel_type: m.channel_type, + enabled: m.enabled, + config_json: m.config_json, + event_filter_json: m.event_filter_json, + daily_report_enabled: m.daily_report_enabled, + daily_report_time: m.daily_report_time, + created_at: m.created_at.to_rfc3339(), + updated_at: m.updated_at.to_rfc3339(), + } + } +} + +impl From for ChatChannelMessageLogInfo { + fn from(m: crate::db::entities::chat_channel_message_log::Model) -> Self { + Self { + id: m.id, + channel_id: m.channel_id, + direction: m.direction, + message_type: m.message_type, + content_preview: m.content_preview, + status: m.status, + error_detail: m.error_detail, + created_at: m.created_at.to_rfc3339(), + } + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 4cc6131..b417223 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,10 +1,13 @@ pub mod agent; +pub mod chat_channel; pub mod conversation; pub mod folder; pub mod message; pub mod system; pub use agent::AgentType; +#[allow(unused_imports)] +pub use chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; pub use conversation::{ AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary, DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats, diff --git a/src-tauri/src/web/handlers/chat_channel.rs b/src-tauri/src/web/handlers/chat_channel.rs new file mode 100644 index 0000000..12f2052 --- /dev/null +++ b/src-tauri/src/web/handlers/chat_channel.rs @@ -0,0 +1,185 @@ +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::chat_channel as cc_commands; +use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; + +// --------------------------------------------------------------------------- +// Param structs +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateChatChannelParams { + pub name: String, + pub channel_type: String, + pub config_json: String, + pub enabled: bool, + pub daily_report_enabled: bool, + pub daily_report_time: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateChatChannelParams { + pub id: i32, + pub name: Option, + pub enabled: Option, + pub config_json: Option, + pub event_filter_json: Option>, + pub daily_report_enabled: Option, + pub daily_report_time: Option>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelIdParams { + pub id: i32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveTokenParams { + pub channel_id: i32, + pub token: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelIdOnlyParams { + pub channel_id: i32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListMessagesParams { + pub channel_id: i32, + pub limit: Option, + pub offset: Option, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +pub async fn list_chat_channels( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + let result = cc_commands::list_chat_channels_core(&state.db).await?; + Ok(Json(result)) +} + +pub async fn create_chat_channel( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = cc_commands::create_chat_channel_core( + &state.db, + params.name, + params.channel_type, + params.config_json, + params.enabled, + params.daily_report_enabled, + params.daily_report_time, + ) + .await?; + Ok(Json(result)) +} + +pub async fn update_chat_channel( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = cc_commands::update_chat_channel_core( + &state.db, + params.id, + params.name, + params.enabled, + params.config_json, + params.event_filter_json, + params.daily_report_enabled, + params.daily_report_time, + ) + .await?; + Ok(Json(result)) +} + +pub async fn delete_chat_channel( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + cc_commands::delete_chat_channel_core(&state.db, params.id).await?; + Ok(Json(())) +} + +pub async fn save_chat_channel_token( + Json(params): Json, +) -> Result, AppCommandError> { + cc_commands::save_chat_channel_token_core(params.channel_id, ¶ms.token)?; + Ok(Json(())) +} + +pub async fn get_chat_channel_has_token( + Json(params): Json, +) -> Result, AppCommandError> { + let has = cc_commands::get_chat_channel_has_token_core(params.channel_id)?; + Ok(Json(has)) +} + +pub async fn delete_chat_channel_token( + Json(params): Json, +) -> Result, AppCommandError> { + cc_commands::delete_chat_channel_token_core(params.channel_id)?; + Ok(Json(())) +} + +pub async fn connect_chat_channel( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + cc_commands::connect_chat_channel_core(&state.db, &state.chat_channel_manager, params.id) + .await?; + Ok(Json(())) +} + +pub async fn disconnect_chat_channel( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + cc_commands::disconnect_chat_channel_core(&state.chat_channel_manager, params.id).await?; + Ok(Json(())) +} + +pub async fn test_chat_channel( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + cc_commands::test_chat_channel_core(&state.db, params.id).await?; + Ok(Json(())) +} + +pub async fn get_chat_channel_status( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + let result = + cc_commands::get_chat_channel_status_core(&state.chat_channel_manager).await?; + Ok(Json(result)) +} + +pub async fn list_chat_channel_messages( + Extension(state): Extension>, + Json(params): Json, +) -> Result>, AppCommandError> { + let result = cc_commands::list_chat_channel_messages_core( + &state.db, + params.channel_id, + params.limit, + params.offset, + ) + .await?; + Ok(Json(result)) +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 333d3b8..243bb1e 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -1,13 +1,14 @@ mod error; +pub mod acp; +pub mod chat_channel; pub mod conversations; pub mod files; -pub mod folders; -pub mod acp; -pub mod terminal; -pub mod system_settings; -pub mod version_control; pub mod folder_commands; -pub mod mcp; +pub mod folders; pub mod git; +pub mod mcp; pub mod project_boot; +pub mod system_settings; +pub mod terminal; +pub mod version_control; pub mod web_server; diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index cc1dcce..dce89b0 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -245,6 +245,7 @@ pub async fn start_web_server( emitter: crate::web::event_bridge::EventEmitter::Tauri(app.clone()), data_dir: app.path().app_data_dir().unwrap_or_default(), web_server_state: WebServerState::new(), // placeholder; not used by handlers + chat_channel_manager: crate::app_state::default_chat_channel_manager(), }); let router = router::build_router(app_state, token.clone(), static_dir); diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 6fbf8cf..8fd2c20 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -180,6 +180,19 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: .route("/start_web_server", post(handlers::web_server::start_web_server)) .route("/stop_web_server", post(handlers::web_server::stop_web_server)) .route("/check_app_update", post(handlers::web_server::check_app_update)) + // ─── Chat Channels ─── + .route("/list_chat_channels", post(handlers::chat_channel::list_chat_channels)) + .route("/create_chat_channel", post(handlers::chat_channel::create_chat_channel)) + .route("/update_chat_channel", post(handlers::chat_channel::update_chat_channel)) + .route("/delete_chat_channel", post(handlers::chat_channel::delete_chat_channel)) + .route("/save_chat_channel_token", post(handlers::chat_channel::save_chat_channel_token)) + .route("/get_chat_channel_has_token", post(handlers::chat_channel::get_chat_channel_has_token)) + .route("/delete_chat_channel_token", post(handlers::chat_channel::delete_chat_channel_token)) + .route("/connect_chat_channel", post(handlers::chat_channel::connect_chat_channel)) + .route("/disconnect_chat_channel", post(handlers::chat_channel::disconnect_chat_channel)) + .route("/test_chat_channel", post(handlers::chat_channel::test_chat_channel)) + .route("/get_chat_channel_status", post(handlers::chat_channel::get_chat_channel_status)) + .route("/list_chat_channel_messages", post(handlers::chat_channel::list_chat_channel_messages)) // ─── Terminal ─── .route("/terminal_spawn", post(handlers::terminal::terminal_spawn)) .route("/terminal_write", post(handlers::terminal::terminal_write)) diff --git a/src/app/settings/chat-channels/page.tsx b/src/app/settings/chat-channels/page.tsx new file mode 100644 index 0000000..e57dae0 --- /dev/null +++ b/src/app/settings/chat-channels/page.tsx @@ -0,0 +1,5 @@ +import { ChatChannelSettings } from "@/components/settings/chat-channel-settings" + +export default function SettingsChatChannelsPage() { + return +} diff --git a/src/components/settings/add-chat-channel-dialog.tsx b/src/components/settings/add-chat-channel-dialog.tsx new file mode 100644 index 0000000..9632136 --- /dev/null +++ b/src/components/settings/add-chat-channel-dialog.tsx @@ -0,0 +1,245 @@ +"use client" + +import { useCallback, useState } from "react" +import { Loader2 } from "lucide-react" +import { useTranslations } from "next-intl" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { + createChatChannel, + saveChatChannelToken, +} from "@/lib/api" +import type { ChannelType } from "@/lib/types" + +interface AddChatChannelDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onChannelAdded: () => void +} + +export function AddChatChannelDialog({ + open, + onOpenChange, + onChannelAdded, +}: AddChatChannelDialogProps) { + const t = useTranslations("ChatChannelSettings") + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const [name, setName] = useState("") + const [channelType, setChannelType] = useState("telegram") + const [token, setToken] = useState("") + const [chatId, setChatId] = useState("") + const [appId, setAppId] = useState("") + const [dailyReportEnabled, setDailyReportEnabled] = useState(false) + const [dailyReportTime, setDailyReportTime] = useState("18:00") + + const resetForm = useCallback(() => { + setName("") + setChannelType("telegram") + setToken("") + setChatId("") + setAppId("") + setDailyReportEnabled(false) + setDailyReportTime("18:00") + setError(null) + }, []) + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) resetForm() + onOpenChange(nextOpen) + }, + [onOpenChange, resetForm], + ) + + const handleSubmit = useCallback(async () => { + if (!name.trim()) { + setError(t("nameRequired")) + return + } + if (!token.trim()) { + setError(t("tokenRequired")) + return + } + if (!chatId.trim()) { + setError(t("chatIdRequired")) + return + } + + setLoading(true) + setError(null) + try { + const configJson = + channelType === "lark" + ? JSON.stringify({ app_id: appId, chat_id: chatId }) + : JSON.stringify({ chat_id: chatId }) + + const channel = await createChatChannel({ + name: name.trim(), + channelType, + configJson, + enabled: true, + dailyReportEnabled, + dailyReportTime: dailyReportEnabled ? dailyReportTime : null, + }) + + await saveChatChannelToken(channel.id, token.trim()) + + handleOpenChange(false) + onChannelAdded() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + setError(msg) + } finally { + setLoading(false) + } + }, [ + name, + token, + chatId, + channelType, + appId, + dailyReportEnabled, + dailyReportTime, + handleOpenChange, + onChannelAdded, + t, + ]) + + return ( + + + + {t("addChannel")} + + +
+
+ + setName(e.target.value)} + placeholder={t("channelNamePlaceholder")} + /> +
+ +
+ + +
+ + {channelType === "lark" && ( +
+ + setAppId(e.target.value)} + placeholder="cli_xxxxx" + /> +
+ )} + +
+ + setToken(e.target.value)} + placeholder={ + channelType === "telegram" + ? "123456:ABC-DEF..." + : "xxxxx" + } + /> +
+ +
+ + setChatId(e.target.value)} + placeholder={ + channelType === "telegram" ? "-100123456789" : "oc_xxxxx" + } + /> +
+ +
+ + +
+ + {dailyReportEnabled && ( +
+ + setDailyReportTime(e.target.value)} + /> +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/src/components/settings/chat-channel-settings.tsx b/src/components/settings/chat-channel-settings.tsx new file mode 100644 index 0000000..7dd1eb5 --- /dev/null +++ b/src/components/settings/chat-channel-settings.tsx @@ -0,0 +1,300 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { + Loader2, + MessageCircle, + Plus, + Power, + PowerOff, + TestTube, + Trash2, +} from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Switch } from "@/components/ui/switch" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { + listChatChannels, + deleteChatChannel, + connectChatChannel, + disconnectChatChannel, + testChatChannel, + updateChatChannel, + getChatChannelStatus, +} from "@/lib/api" +import type { ChatChannelInfo, ChannelStatusInfo } from "@/lib/types" +import { AddChatChannelDialog } from "./add-chat-channel-dialog" + +export function ChatChannelSettings() { + const t = useTranslations("ChatChannelSettings") + const [channels, setChannels] = useState([]) + const [statuses, setStatuses] = useState([]) + const [loading, setLoading] = useState(true) + const [addDialogOpen, setAddDialogOpen] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(null) + const [actionLoading, setActionLoading] = useState(null) + + const loadChannels = useCallback(async () => { + try { + const [chs, sts] = await Promise.all([ + listChatChannels(), + getChatChannelStatus().catch(() => []), + ]) + setChannels(chs) + setStatuses(sts) + } catch (err) { + toast.error(t("loadFailed")) + } finally { + setLoading(false) + } + }, [t]) + + useEffect(() => { + loadChannels().catch(console.error) + }, [loadChannels]) + + const handleToggleEnabled = useCallback( + async (ch: ChatChannelInfo) => { + try { + await updateChatChannel({ id: ch.id, enabled: !ch.enabled }) + await loadChannels() + } catch (err) { + toast.error(t("saveFailed")) + } + }, + [loadChannels, t], + ) + + const handleConnect = useCallback( + async (id: number) => { + setActionLoading(id) + try { + await connectChatChannel(id) + toast.success(t("connectSuccess")) + await loadChannels() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.error(t("connectFailed") + ": " + msg) + } finally { + setActionLoading(null) + } + }, + [loadChannels, t], + ) + + const handleDisconnect = useCallback( + async (id: number) => { + setActionLoading(id) + try { + await disconnectChatChannel(id) + toast.success(t("disconnectSuccess")) + await loadChannels() + } catch (err) { + toast.error(t("disconnectFailed")) + } finally { + setActionLoading(null) + } + }, + [loadChannels, t], + ) + + const handleTest = useCallback( + async (id: number) => { + setActionLoading(id) + try { + await testChatChannel(id) + toast.success(t("testSuccess")) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.error(t("testFailed") + ": " + msg) + } finally { + setActionLoading(null) + } + }, + [t], + ) + + const handleDelete = useCallback(async () => { + if (!deleteTarget) return + try { + await deleteChatChannel(deleteTarget.id) + toast.success(t("deleteSuccess")) + setDeleteTarget(null) + await loadChannels() + } catch (err) { + toast.error(t("deleteFailed")) + } + }, [deleteTarget, loadChannels, t]) + + const getChannelStatus = (id: number) => + statuses.find((s) => s.channel_id === id)?.status ?? "disconnected" + + if (loading) { + return ( +
+ + {t("loading")} +
+ ) + } + + return ( +
+
+
+
+
+

{t("sectionTitle")}

+

+ {t("sectionDescription")} +

+
+ +
+
+ + {channels.length === 0 ? ( +
+ +

+ {t("noChannels")} +

+
+ ) : ( +
+ {channels.map((ch) => { + const status = getChannelStatus(ch.id) + const isConnected = status === "connected" + const isLoading = actionLoading === ch.id + + return ( +
+
+
+ {ch.name} + + {ch.channel_type} + + +
+
+ {ch.daily_report_enabled && ( + + {t("dailyReport")}: {ch.daily_report_time || "18:00"} + + )} +
+
+ +
+ handleToggleEnabled(ch)} + /> + {isConnected ? ( + + ) : ( + + )} + + +
+
+ ) + })} +
+ )} +
+ + + + !open && setDeleteTarget(null)} + > + + + {t("deleteConfirmTitle")} + + {t("deleteConfirmMessage")} + + + + {t("cancel")} + + {t("delete")} + + + + +
+ ) +} diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 838f0b4..bbeedba 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -12,6 +12,7 @@ import { GitBranch, Globe, Keyboard, + MessageCircle, Palette, PlugZap, Settings, @@ -33,6 +34,7 @@ interface SettingsNavItem { | "skills" | "shortcuts" | "version_control" + | "chat_channels" | "system" | "web_service" icon: ComponentType<{ className?: string }> @@ -69,6 +71,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [ labelKey: "version_control", icon: GitBranch, }, + { + href: "/settings/chat-channels", + labelKey: "chat_channels", + icon: MessageCircle, + }, { href: "/settings/web-service", labelKey: "web_service", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b6d297c..87bebe3 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -92,6 +92,7 @@ "shortcuts": "Shortcuts", "version_control": "Version Control", "system": "System", + "chat_channels": "Chat Channels", "web_service": "Web Service" } }, @@ -1670,5 +1671,36 @@ "emptyDirectory": "This directory is empty", "errorLoadingDir": "Failed to load directory", "permissionDenied": "Permission denied" + }, + "ChatChannelSettings": { + "loading": "Loading...", + "sectionTitle": "Chat Channels", + "sectionDescription": "Configure IM bots to receive event notifications and query coding activity.", + "addChannel": "Add Channel", + "noChannels": "No chat channels configured yet.", + "channelName": "Name", + "channelNamePlaceholder": "My Telegram Bot", + "channelType": "Channel Type", + "lark": "Lark (Feishu)", + "dailyReport": "Daily Report", + "dailyReportTime": "Report Time", + "nameRequired": "Channel name is required.", + "tokenRequired": "Token is required.", + "chatIdRequired": "Chat ID is required.", + "loadFailed": "Failed to load channels.", + "saveFailed": "Failed to save changes.", + "connectSuccess": "Channel connected.", + "connectFailed": "Failed to connect", + "disconnectSuccess": "Channel disconnected.", + "disconnectFailed": "Failed to disconnect.", + "testSuccess": "Connection test passed.", + "testFailed": "Connection test failed", + "deleteSuccess": "Channel deleted.", + "deleteFailed": "Failed to delete channel.", + "deleteConfirmTitle": "Delete Channel", + "deleteConfirmMessage": "This will permanently delete the channel and its message logs. Are you sure?", + "cancel": "Cancel", + "delete": "Delete", + "create": "Create" } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b5daf0c..a6b6ba8 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -92,6 +92,7 @@ "shortcuts": "快捷键", "version_control": "版本控制", "system": "系统", + "chat_channels": "消息渠道", "web_service": "Web 服务" } }, @@ -1670,5 +1671,36 @@ "emptyDirectory": "此目录为空", "errorLoadingDir": "加载目录失败", "permissionDenied": "权限不足" + }, + "ChatChannelSettings": { + "loading": "加载中...", + "sectionTitle": "消息渠道", + "sectionDescription": "配置 IM 机器人,接收事件通知和查询编码活动。", + "addChannel": "添加渠道", + "noChannels": "尚未配置任何消息渠道。", + "channelName": "名称", + "channelNamePlaceholder": "我的 Telegram 机器人", + "channelType": "渠道类型", + "lark": "飞书", + "dailyReport": "每日报告", + "dailyReportTime": "推送时间", + "nameRequired": "请输入渠道名称。", + "tokenRequired": "请输入 Token。", + "chatIdRequired": "请输入 Chat ID。", + "loadFailed": "加载渠道失败。", + "saveFailed": "保存失败。", + "connectSuccess": "渠道已连接。", + "connectFailed": "连接失败", + "disconnectSuccess": "渠道已断开。", + "disconnectFailed": "断开连接失败。", + "testSuccess": "连接测试通过。", + "testFailed": "连接测试失败", + "deleteSuccess": "渠道已删除。", + "deleteFailed": "删除渠道失败。", + "deleteConfirmTitle": "删除渠道", + "deleteConfirmMessage": "将永久删除该渠道及其消息日志,确定吗?", + "cancel": "取消", + "delete": "删除", + "create": "创建" } } diff --git a/src/lib/api.ts b/src/lib/api.ts index c9889b0..9f2b4bb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -54,6 +54,9 @@ import type { McpMarketplaceProvider, McpMarketplaceItem, McpMarketplaceServerDetail, + ChatChannelInfo, + ChannelStatusInfo, + ChatChannelMessageLog, } from "./types" export async function listConversations(params?: { @@ -1304,3 +1307,98 @@ export async function stopWebServer(): Promise { export async function getWebServerStatus(): Promise { return getTransport().call("get_web_server_status") } + +// ─── Chat Channels ─── + +export async function listChatChannels(): Promise { + return getTransport().call("list_chat_channels") +} + +export async function createChatChannel(params: { + name: string + channelType: string + configJson: string + enabled: boolean + dailyReportEnabled: boolean + dailyReportTime?: string | null +}): Promise { + return getTransport().call("create_chat_channel", { + name: params.name, + channelType: params.channelType, + configJson: params.configJson, + enabled: params.enabled, + dailyReportEnabled: params.dailyReportEnabled, + dailyReportTime: params.dailyReportTime ?? null, + }) +} + +export async function updateChatChannel(params: { + id: number + name?: string | null + enabled?: boolean | null + configJson?: string | null + eventFilterJson?: string | null + dailyReportEnabled?: boolean | null + dailyReportTime?: string | null +}): Promise { + return getTransport().call("update_chat_channel", { + id: params.id, + name: params.name ?? null, + enabled: params.enabled ?? null, + configJson: params.configJson ?? null, + eventFilterJson: params.eventFilterJson ?? null, + dailyReportEnabled: params.dailyReportEnabled ?? null, + dailyReportTime: params.dailyReportTime ?? null, + }) +} + +export async function deleteChatChannel(id: number): Promise { + return getTransport().call("delete_chat_channel", { id }) +} + +export async function saveChatChannelToken( + channelId: number, + token: string, +): Promise { + return getTransport().call("save_chat_channel_token", { channelId, token }) +} + +export async function getChatChannelHasToken( + channelId: number, +): Promise { + return getTransport().call("get_chat_channel_has_token", { channelId }) +} + +export async function deleteChatChannelToken( + channelId: number, +): Promise { + return getTransport().call("delete_chat_channel_token", { channelId }) +} + +export async function connectChatChannel(id: number): Promise { + return getTransport().call("connect_chat_channel", { id }) +} + +export async function disconnectChatChannel(id: number): Promise { + return getTransport().call("disconnect_chat_channel", { id }) +} + +export async function testChatChannel(id: number): Promise { + return getTransport().call("test_chat_channel", { id }) +} + +export async function getChatChannelStatus(): Promise { + return getTransport().call("get_chat_channel_status") +} + +export async function listChatChannelMessages(params: { + channelId: number + limit?: number + offset?: number +}): Promise { + return getTransport().call("list_chat_channel_messages", { + channelId: params.channelId, + limit: params.limit ?? null, + offset: params.offset ?? null, + }) +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 4c360a3..e4c19f8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -841,3 +841,44 @@ export interface PreflightResult { passed: boolean checks: CheckItem[] } + +// ─── Chat Channels ─── + +export type ChannelType = "lark" | "telegram" + +export type ChannelConnectionStatus = + | "connected" + | "connecting" + | "disconnected" + | "error" + +export interface ChatChannelInfo { + id: number + name: string + channel_type: ChannelType + enabled: boolean + config_json: string + event_filter_json: string | null + daily_report_enabled: boolean + daily_report_time: string | null + created_at: string + updated_at: string +} + +export interface ChannelStatusInfo { + channel_id: number + name: string + channel_type: ChannelType + status: ChannelConnectionStatus +} + +export interface ChatChannelMessageLog { + id: number + channel_id: number + direction: "outbound" | "inbound" + message_type: string + content_preview: string + status: "sent" | "failed" + error_detail: string | null + created_at: string +}