初步集成消息通道,支持Telegram + Lark机器人
This commit is contained in:
63
src-tauri/Cargo.lock
generated
63
src-tauri/Cargo.lock
generated
@@ -386,7 +386,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite 0.28.0",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -850,6 +850,7 @@ name = "codeg"
|
|||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol-schema",
|
"agent-client-protocol-schema",
|
||||||
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bzip2",
|
"bzip2",
|
||||||
@@ -858,11 +859,13 @@ dependencies = [
|
|||||||
"fix-path-env",
|
"fix-path-env",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
|
"futures-util",
|
||||||
"keyring",
|
"keyring",
|
||||||
"kill_tree",
|
"kill_tree",
|
||||||
"mac-notification-sys",
|
"mac-notification-sys",
|
||||||
"notify",
|
"notify",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
|
"prost",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"sacp",
|
"sacp",
|
||||||
@@ -882,6 +885,7 @@ dependencies = [
|
|||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.26.2",
|
||||||
"toml 0.8.2",
|
"toml 0.8.2",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
@@ -4292,6 +4296,29 @@ dependencies = [
|
|||||||
"yansi",
|
"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]]
|
[[package]]
|
||||||
name = "ptr_meta"
|
name = "ptr_meta"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -6731,6 +6758,20 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
@@ -6740,7 +6781,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tungstenite",
|
"tungstenite 0.28.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6984,6 +7025,24 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "tungstenite"
|
name = "tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ tauri-build = { version = "2", features = [], optional = true }
|
|||||||
tauri = { version = "2", features = [], optional = true }
|
tauri = { version = "2", features = [], optional = true }
|
||||||
tauri-plugin-opener = { version = "2", optional = true }
|
tauri-plugin-opener = { version = "2", optional = true }
|
||||||
tauri-plugin-dialog = { version = "2", optional = true }
|
tauri-plugin-dialog = { version = "2", optional = true }
|
||||||
|
async-trait = "0.1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
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 }
|
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
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]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-window-state = { version = "2", optional = true }
|
tauri-plugin-window-state = { version = "2", optional = true }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::acp::manager::ConnectionManager;
|
use crate::acp::manager::ConnectionManager;
|
||||||
|
use crate::chat_channel::manager::ChatChannelManager;
|
||||||
use crate::db::AppDatabase;
|
use crate::db::AppDatabase;
|
||||||
use crate::terminal::manager::TerminalManager;
|
use crate::terminal::manager::TerminalManager;
|
||||||
use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster};
|
use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster};
|
||||||
@@ -15,6 +16,7 @@ pub struct AppState {
|
|||||||
pub emitter: EventEmitter,
|
pub emitter: EventEmitter,
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
pub web_server_state: WebServerState,
|
pub web_server_state: WebServerState,
|
||||||
|
pub chat_channel_manager: ChatChannelManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_connection_manager() -> ConnectionManager {
|
pub fn default_connection_manager() -> ConnectionManager {
|
||||||
@@ -24,3 +26,7 @@ pub fn default_connection_manager() -> ConnectionManager {
|
|||||||
pub fn default_terminal_manager() -> TerminalManager {
|
pub fn default_terminal_manager() -> TerminalManager {
|
||||||
TerminalManager::new()
|
TerminalManager::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn default_chat_channel_manager() -> ChatChannelManager {
|
||||||
|
ChatChannelManager::new()
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,8 +45,15 @@ async fn main() {
|
|||||||
emitter,
|
emitter,
|
||||||
data_dir,
|
data_dir,
|
||||||
web_server_state: WebServerState::new(),
|
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
|
// Build router
|
||||||
let router = codeg_lib::web::router::build_router(state, token.clone(), static_dir);
|
let router = codeg_lib::web::router::build_router(state, token.clone(), static_dir);
|
||||||
|
|
||||||
|
|||||||
622
src-tauri/src/chat_channel/backends/lark.rs
Normal file
622
src-tauri/src/chat_channel/backends/lark.rs
Normal file
@@ -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<FrameHeader>,
|
||||||
|
#[prost(string, tag = 6)]
|
||||||
|
payload_encoding: String,
|
||||||
|
#[prost(string, tag = 7)]
|
||||||
|
payload_type: String,
|
||||||
|
#[prost(bytes = "vec", tag = 8)]
|
||||||
|
payload: Vec<u8>,
|
||||||
|
#[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<String>,
|
||||||
|
expire: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SendMessageRequest {
|
||||||
|
receive_id: String,
|
||||||
|
msg_type: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SendMessageResponse {
|
||||||
|
code: i32,
|
||||||
|
msg: String,
|
||||||
|
data: Option<SendMessageData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SendMessageData {
|
||||||
|
message_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WsConnectResponse {
|
||||||
|
code: i32,
|
||||||
|
msg: String,
|
||||||
|
data: Option<WsConnectData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WsConnectData {
|
||||||
|
#[serde(rename = "URL")]
|
||||||
|
url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Token cache ──
|
||||||
|
|
||||||
|
struct TokenCache {
|
||||||
|
token: String,
|
||||||
|
expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-part frame cache ──
|
||||||
|
|
||||||
|
struct PartialMessage {
|
||||||
|
parts: HashMap<i32, Vec<u8>>,
|
||||||
|
total: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LarkBackend ──
|
||||||
|
|
||||||
|
pub struct LarkBackend {
|
||||||
|
app_id: String,
|
||||||
|
app_secret: String,
|
||||||
|
chat_id: String,
|
||||||
|
channel_id: i32,
|
||||||
|
client: reqwest::Client,
|
||||||
|
token_cache: Arc<RwLock<Option<TokenCache>>>,
|
||||||
|
status: Arc<Mutex<ChannelConnectionStatus>>,
|
||||||
|
shutdown_tx: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, ChatChannelError> {
|
||||||
|
{
|
||||||
|
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<SentMessageId, ChatChannelError> {
|
||||||
|
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<IncomingCommand>,
|
||||||
|
) -> 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<String, PartialMessage> = 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::<serde_json::Value>(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<IncomingCommand>,
|
||||||
|
) {
|
||||||
|
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::<serde_json::Value>(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<String, ChatChannelError> {
|
||||||
|
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<IncomingCommand>,
|
||||||
|
) -> 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<SentMessageId, ChatChannelError> {
|
||||||
|
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<SentMessageId, ChatChannelError> {
|
||||||
|
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<serde_json::Value> = 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<serde_json::Value> = 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
2
src-tauri/src/chat_channel/backends/mod.rs
Normal file
2
src-tauri/src/chat_channel/backends/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod lark;
|
||||||
|
pub mod telegram;
|
||||||
255
src-tauri/src/chat_channel/backends/telegram.rs
Normal file
255
src-tauri/src/chat_channel/backends/telegram.rs
Normal file
@@ -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<Mutex<ChannelConnectionStatus>>,
|
||||||
|
channel_id: i32,
|
||||||
|
shutdown_tx: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<IncomingCommand>,
|
||||||
|
) -> 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::<serde_json::Value>().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<SentMessageId, ChatChannelError> {
|
||||||
|
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<SentMessageId, ChatChannelError> {
|
||||||
|
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('!', "\\!")
|
||||||
|
}
|
||||||
97
src-tauri/src/chat_channel/command_dispatcher.rs
Normal file
97
src-tauri/src/chat_channel/command_dispatcher.rs
Normal file
@@ -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<IncomingCommand>,
|
||||||
|
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::<i32>() {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src-tauri/src/chat_channel/command_handlers.rs
Normal file
218
src-tauri/src/chat_channel/command_handlers.rs
Normal file
@@ -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<String, u32> = std::collections::HashMap::new();
|
||||||
|
let mut titles: Vec<String> = 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 <ID> - 会话详情\n\
|
||||||
|
/today - 今日活动汇总\n\
|
||||||
|
/status - 渠道连接状态\n\
|
||||||
|
/help - 显示帮助",
|
||||||
|
)
|
||||||
|
.with_title("Codeg Bot 帮助")
|
||||||
|
}
|
||||||
39
src-tauri/src/chat_channel/error.rs
Normal file
39
src-tauri/src/chat_channel/error.rs
Normal file
@@ -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<ChatChannelError> 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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
217
src-tauri/src/chat_channel/event_subscriber.rs
Normal file
217
src-tauri/src/chat_channel/event_subscriber.rs
Normal file
@@ -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<WebEventBroadcaster>,
|
||||||
|
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::<Vec<String>>(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),
|
||||||
|
))
|
||||||
|
}
|
||||||
255
src-tauri/src/chat_channel/manager.rs
Normal file
255
src-tauri/src/chat_channel/manager.rs
Normal file
@@ -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<dyn ChatChannelBackend>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner state shared across clones.
|
||||||
|
struct Inner {
|
||||||
|
channels: Mutex<HashMap<i32, ActiveChannel>>,
|
||||||
|
command_tx: mpsc::Sender<IncomingCommand>,
|
||||||
|
command_rx: Mutex<Option<mpsc::Receiver<IncomingCommand>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ChatChannelManager {
|
||||||
|
inner: Arc<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<IncomingCommand> {
|
||||||
|
self.inner.command_tx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the command receiver (can only be called once, at startup).
|
||||||
|
pub async fn take_command_receiver(&self) -> Option<mpsc::Receiver<IncomingCommand>> {
|
||||||
|
self.inner.command_rx.lock().await.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_channel(
|
||||||
|
&self,
|
||||||
|
id: i32,
|
||||||
|
name: String,
|
||||||
|
channel_type: ChannelType,
|
||||||
|
backend: Box<dyn ChatChannelBackend>,
|
||||||
|
) -> 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<SentMessageId, ChatChannelError> {
|
||||||
|
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<crate::models::ChannelStatusInfo> {
|
||||||
|
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<WebEventBroadcaster>,
|
||||||
|
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<dyn ChatChannelBackend> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src-tauri/src/chat_channel/message_formatter.rs
Normal file
86
src-tauri/src/chat_channel/message_formatter.rs
Normal file
@@ -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<String>,
|
||||||
|
pub key_activities: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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("每日编码报告")
|
||||||
|
}
|
||||||
10
src-tauri/src/chat_channel/mod.rs
Normal file
10
src-tauri/src/chat_channel/mod.rs
Normal file
@@ -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;
|
||||||
130
src-tauri/src/chat_channel/scheduler.rs
Normal file
130
src-tauri/src/chat_channel/scheduler.rs
Normal file
@@ -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<String, u32> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
let mut folder_ids: HashSet<i32> = HashSet::new();
|
||||||
|
let mut activities: Vec<String> = 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<String> = 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src-tauri/src/chat_channel/traits.rs
Normal file
53
src-tauri/src/chat_channel/traits.rs
Normal file
@@ -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<IncomingCommand>,
|
||||||
|
) -> 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<SentMessageId, ChatChannelError>;
|
||||||
|
|
||||||
|
/// Send a rich/structured message (Telegram Markdown / Lark Card).
|
||||||
|
async fn send_rich_message(
|
||||||
|
&self,
|
||||||
|
message: &RichMessage,
|
||||||
|
) -> Result<SentMessageId, ChatChannelError>;
|
||||||
|
|
||||||
|
/// [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<SentMessageId, ChatChannelError> {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
125
src-tauri/src/chat_channel/types.rs
Normal file
125
src-tauri/src/chat_channel/types.rs
Normal file
@@ -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<String>,
|
||||||
|
pub body: String,
|
||||||
|
pub fields: Vec<(String, String)>,
|
||||||
|
pub level: MessageLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RichMessage {
|
||||||
|
pub fn info(body: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
title: None,
|
||||||
|
body: body.into(),
|
||||||
|
fields: Vec::new(),
|
||||||
|
level: MessageLevel::Info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_title(mut self, title: impl Into<String>) -> Self {
|
||||||
|
self.title = Some(title.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> 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<MessageButton>,
|
||||||
|
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<String> = self
|
||||||
|
.buttons
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("[{}]", b.label))
|
||||||
|
.collect();
|
||||||
|
msg.body.push_str(&format!("\n\n{}", button_text.join(" ")));
|
||||||
|
}
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
}
|
||||||
380
src-tauri/src/commands/chat_channel.rs
Normal file
380
src-tauri/src/commands/chat_channel.rs
Normal file
@@ -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<Vec<ChatChannelInfo>, 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<String>,
|
||||||
|
) -> Result<ChatChannelInfo, AppCommandError> {
|
||||||
|
// 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<String>,
|
||||||
|
enabled: Option<bool>,
|
||||||
|
config_json: Option<String>,
|
||||||
|
event_filter_json: Option<Option<String>>,
|
||||||
|
daily_report_enabled: Option<bool>,
|
||||||
|
daily_report_time: Option<Option<String>>,
|
||||||
|
) -> Result<ChatChannelInfo, AppCommandError> {
|
||||||
|
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<dyn crate::chat_channel::traits::ChatChannelBackend> = 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<bool, AppCommandError> {
|
||||||
|
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<Vec<ChannelStatusInfo>, AppCommandError> {
|
||||||
|
Ok(manager.get_status().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_chat_channel_messages_core(
|
||||||
|
db: &AppDatabase,
|
||||||
|
channel_id: i32,
|
||||||
|
limit: Option<u64>,
|
||||||
|
offset: Option<u64>,
|
||||||
|
) -> Result<Vec<ChatChannelMessageLogInfo>, 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<Vec<ChatChannelInfo>, 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<String>,
|
||||||
|
) -> Result<ChatChannelInfo, AppCommandError> {
|
||||||
|
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<String>,
|
||||||
|
enabled: Option<bool>,
|
||||||
|
config_json: Option<String>,
|
||||||
|
event_filter_json: Option<Option<String>>,
|
||||||
|
daily_report_enabled: Option<bool>,
|
||||||
|
daily_report_time: Option<Option<String>>,
|
||||||
|
) -> Result<ChatChannelInfo, AppCommandError> {
|
||||||
|
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<bool, AppCommandError> {
|
||||||
|
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<Vec<ChannelStatusInfo>, 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<u64>,
|
||||||
|
offset: Option<u64>,
|
||||||
|
) -> Result<Vec<ChatChannelMessageLogInfo>, AppCommandError> {
|
||||||
|
list_chat_channel_messages_core(&db, channel_id, limit, offset).await
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod acp;
|
pub mod acp;
|
||||||
|
pub mod chat_channel;
|
||||||
pub mod conversations;
|
pub mod conversations;
|
||||||
pub mod folder_commands;
|
pub mod folder_commands;
|
||||||
pub mod folders;
|
pub mod folders;
|
||||||
|
|||||||
31
src-tauri/src/db/entities/chat_channel.rs
Normal file
31
src-tauri/src/db/entities/chat_channel.rs
Normal file
@@ -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<String>,
|
||||||
|
pub daily_report_enabled: bool,
|
||||||
|
pub daily_report_time: Option<String>,
|
||||||
|
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<super::chat_channel_message_log::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::MessageLogs.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
33
src-tauri/src/db/entities/chat_channel_message_log.rs
Normal file
33
src-tauri/src/db/entities/chat_channel_message_log.rs
Normal file
@@ -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<String>,
|
||||||
|
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<super::chat_channel::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::ChatChannel.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod agent_setting;
|
pub mod agent_setting;
|
||||||
pub mod app_metadata;
|
pub mod app_metadata;
|
||||||
|
pub mod chat_channel;
|
||||||
|
pub mod chat_channel_message_log;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
pub mod folder_command;
|
pub mod folder_command;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
pub use super::agent_setting::Entity as AgentSetting;
|
pub use super::agent_setting::Entity as AgentSetting;
|
||||||
pub use super::app_metadata::Entity as AppMetadata;
|
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::conversation::Entity as Conversation;
|
||||||
pub use super::folder::Entity as Folder;
|
pub use super::folder::Entity as Folder;
|
||||||
pub use super::folder_command::Entity as FolderCommand;
|
pub use super::folder_command::Entity as FolderCommand;
|
||||||
|
|||||||
179
src-tauri/src/db/migration/m20260330_000001_chat_channel.rs
Normal file
179
src-tauri/src/db/migration/m20260330_000001_chat_channel.rs
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ mod m20260219_000001_folder_command;
|
|||||||
mod m20260221_000001_folder_is_open;
|
mod m20260221_000001_folder_is_open;
|
||||||
mod m20260226_000001_agent_setting;
|
mod m20260226_000001_agent_setting;
|
||||||
mod m20260227_000001_folder_parent_branch;
|
mod m20260227_000001_folder_parent_branch;
|
||||||
|
mod m20260330_000001_chat_channel;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -16,6 +17,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260221_000001_folder_is_open::Migration),
|
Box::new(m20260221_000001_folder_is_open::Migration),
|
||||||
Box::new(m20260226_000001_agent_setting::Migration),
|
Box::new(m20260226_000001_agent_setting::Migration),
|
||||||
Box::new(m20260227_000001_folder_parent_branch::Migration),
|
Box::new(m20260227_000001_folder_parent_branch::Migration),
|
||||||
|
Box::new(m20260330_000001_chat_channel::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
70
src-tauri/src/db/service/chat_channel_message_log_service.rs
Normal file
70
src-tauri/src/db/service/chat_channel_message_log_service.rs
Normal file
@@ -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<String>,
|
||||||
|
) -> 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<Vec<chat_channel_message_log::Model>, 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<u64, DbError> {
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src-tauri/src/db/service/chat_channel_service.rs
Normal file
98
src-tauri/src/db/service/chat_channel_service.rs
Normal file
@@ -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<String>,
|
||||||
|
) -> Result<chat_channel::Model, DbError> {
|
||||||
|
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<String>,
|
||||||
|
enabled: Option<bool>,
|
||||||
|
config_json: Option<String>,
|
||||||
|
event_filter_json: Option<Option<String>>,
|
||||||
|
daily_report_enabled: Option<bool>,
|
||||||
|
daily_report_time: Option<Option<String>>,
|
||||||
|
) -> Result<chat_channel::Model, DbError> {
|
||||||
|
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<Option<chat_channel::Model>, DbError> {
|
||||||
|
Ok(chat_channel::Entity::find_by_id(id).one(conn).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_all(conn: &DatabaseConnection) -> Result<Vec<chat_channel::Model>, DbError> {
|
||||||
|
Ok(chat_channel::Entity::find()
|
||||||
|
.order_by_asc(chat_channel::Column::Id)
|
||||||
|
.all(conn)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_enabled(conn: &DatabaseConnection) -> Result<Vec<chat_channel::Model>, DbError> {
|
||||||
|
Ok(chat_channel::Entity::find()
|
||||||
|
.filter(chat_channel::Column::Enabled.eq(true))
|
||||||
|
.order_by_asc(chat_channel::Column::Id)
|
||||||
|
.all(conn)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod agent_setting_service;
|
pub mod agent_setting_service;
|
||||||
pub mod app_metadata_service;
|
pub mod app_metadata_service;
|
||||||
|
pub mod chat_channel_message_log_service;
|
||||||
|
pub mod chat_channel_service;
|
||||||
pub mod conversation_service;
|
pub mod conversation_service;
|
||||||
pub mod folder_command_service;
|
pub mod folder_command_service;
|
||||||
pub mod folder_service;
|
pub mod folder_service;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ fn token_key(account_id: &str) -> String {
|
|||||||
format!("github-token:{}", account_id)
|
format!("github-token:{}", account_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn channel_token_key(channel_id: i32) -> String {
|
||||||
|
format!("chat-channel:{}", channel_id)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tauri mode: OS keyring ──
|
// ── Tauri mode: OS keyring ──
|
||||||
|
|
||||||
#[cfg(feature = "tauri-runtime")]
|
#[cfg(feature = "tauri-runtime")]
|
||||||
@@ -87,3 +91,51 @@ pub fn delete_token(account_id: &str) -> Result<(), String> {
|
|||||||
tokens.remove(&token_key(account_id));
|
tokens.remove(&token_key(account_id));
|
||||||
write_tokens(&tokens)
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod acp;
|
mod acp;
|
||||||
mod app_error;
|
mod app_error;
|
||||||
pub mod app_state;
|
pub mod app_state;
|
||||||
|
pub mod chat_channel;
|
||||||
mod commands;
|
mod commands;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod git_credential;
|
pub mod git_credential;
|
||||||
@@ -17,10 +18,11 @@ mod tauri_app {
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use crate::acp::manager::ConnectionManager;
|
use crate::acp::manager::ConnectionManager;
|
||||||
|
use crate::chat_channel::manager::ChatChannelManager;
|
||||||
use crate::commands::{
|
use crate::commands::{
|
||||||
acp as acp_commands, conversations, folder_commands, folders, mcp as mcp_commands,
|
acp as acp_commands, chat_channel as chat_channel_commands, conversations, folder_commands,
|
||||||
notification, project_boot, system_settings, terminal as terminal_commands, version_control,
|
folders, mcp as mcp_commands, notification, project_boot, system_settings,
|
||||||
windows,
|
terminal as terminal_commands, version_control, windows,
|
||||||
};
|
};
|
||||||
use crate::terminal::manager::TerminalManager;
|
use crate::terminal::manager::TerminalManager;
|
||||||
use crate::{db, network, process, web};
|
use crate::{db, network, process, web};
|
||||||
@@ -52,6 +54,7 @@ mod tauri_app {
|
|||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.manage(ConnectionManager::new())
|
.manage(ConnectionManager::new())
|
||||||
.manage(TerminalManager::new())
|
.manage(TerminalManager::new())
|
||||||
|
.manage(ChatChannelManager::new())
|
||||||
.manage(windows::SettingsWindowState::new())
|
.manage(windows::SettingsWindowState::new())
|
||||||
.manage(windows::CommitWindowState::new())
|
.manage(windows::CommitWindowState::new())
|
||||||
.manage(windows::MergeWindowState::new())
|
.manage(windows::MergeWindowState::new())
|
||||||
@@ -78,6 +81,19 @@ mod tauri_app {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start chat channel background tasks
|
||||||
|
{
|
||||||
|
let ccm = app.state::<ChatChannelManager>();
|
||||||
|
let broadcaster = app
|
||||||
|
.state::<std::sync::Arc<web::event_bridge::WebEventBroadcaster>>();
|
||||||
|
let db_conn = app.state::<db::AppDatabase>().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
|
// Restore previously open folders or show welcome
|
||||||
let db = app.state::<db::AppDatabase>();
|
let db = app.state::<db::AppDatabase>();
|
||||||
let open_folders = tauri::async_runtime::block_on(
|
let open_folders = tauri::async_runtime::block_on(
|
||||||
@@ -351,6 +367,18 @@ mod tauri_app {
|
|||||||
mcp_commands::mcp_set_server_apps,
|
mcp_commands::mcp_set_server_apps,
|
||||||
mcp_commands::mcp_remove_server,
|
mcp_commands::mcp_remove_server,
|
||||||
notification::send_notification,
|
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::start_web_server,
|
||||||
web::stop_web_server,
|
web::stop_web_server,
|
||||||
web::get_web_server_status,
|
web::get_web_server_status,
|
||||||
|
|||||||
67
src-tauri/src/models/chat_channel.rs
Normal file
67
src-tauri/src/models/chat_channel.rs
Normal file
@@ -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<String>,
|
||||||
|
pub daily_report_enabled: bool,
|
||||||
|
pub daily_report_time: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<crate::db::entities::chat_channel::Model> 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<crate::db::entities::chat_channel_message_log::Model> 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
pub mod agent;
|
pub mod agent;
|
||||||
|
pub mod chat_channel;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
|
|
||||||
pub use agent::AgentType;
|
pub use agent::AgentType;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
|
||||||
pub use conversation::{
|
pub use conversation::{
|
||||||
AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary,
|
AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary,
|
||||||
DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats,
|
DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats,
|
||||||
|
|||||||
185
src-tauri/src/web/handlers/chat_channel.rs
Normal file
185
src-tauri/src/web/handlers/chat_channel.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateChatChannelParams {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
pub config_json: Option<String>,
|
||||||
|
pub event_filter_json: Option<Option<String>>,
|
||||||
|
pub daily_report_enabled: Option<bool>,
|
||||||
|
pub daily_report_time: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u64>,
|
||||||
|
pub offset: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn list_chat_channels(
|
||||||
|
Extension(state): Extension<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<ChatChannelInfo>>, AppCommandError> {
|
||||||
|
let result = cc_commands::list_chat_channels_core(&state.db).await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_chat_channel(
|
||||||
|
Extension(state): Extension<Arc<AppState>>,
|
||||||
|
Json(params): Json<CreateChatChannelParams>,
|
||||||
|
) -> Result<Json<ChatChannelInfo>, 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<Arc<AppState>>,
|
||||||
|
Json(params): Json<UpdateChatChannelParams>,
|
||||||
|
) -> Result<Json<ChatChannelInfo>, 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<Arc<AppState>>,
|
||||||
|
Json(params): Json<ChannelIdParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
cc_commands::delete_chat_channel_core(&state.db, params.id).await?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_chat_channel_token(
|
||||||
|
Json(params): Json<SaveTokenParams>,
|
||||||
|
) -> Result<Json<()>, 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<ChannelIdOnlyParams>,
|
||||||
|
) -> Result<Json<bool>, 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<ChannelIdOnlyParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
cc_commands::delete_chat_channel_token_core(params.channel_id)?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn connect_chat_channel(
|
||||||
|
Extension(state): Extension<Arc<AppState>>,
|
||||||
|
Json(params): Json<ChannelIdParams>,
|
||||||
|
) -> Result<Json<()>, 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<Arc<AppState>>,
|
||||||
|
Json(params): Json<ChannelIdParams>,
|
||||||
|
) -> Result<Json<()>, 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<Arc<AppState>>,
|
||||||
|
Json(params): Json<ChannelIdParams>,
|
||||||
|
) -> Result<Json<()>, AppCommandError> {
|
||||||
|
cc_commands::test_chat_channel_core(&state.db, params.id).await?;
|
||||||
|
Ok(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_chat_channel_status(
|
||||||
|
Extension(state): Extension<Arc<AppState>>,
|
||||||
|
) -> Result<Json<Vec<ChannelStatusInfo>>, 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<Arc<AppState>>,
|
||||||
|
Json(params): Json<ListMessagesParams>,
|
||||||
|
) -> Result<Json<Vec<ChatChannelMessageLogInfo>>, AppCommandError> {
|
||||||
|
let result = cc_commands::list_chat_channel_messages_core(
|
||||||
|
&state.db,
|
||||||
|
params.channel_id,
|
||||||
|
params.limit,
|
||||||
|
params.offset,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
mod error;
|
mod error;
|
||||||
|
pub mod acp;
|
||||||
|
pub mod chat_channel;
|
||||||
pub mod conversations;
|
pub mod conversations;
|
||||||
pub mod files;
|
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 folder_commands;
|
||||||
pub mod mcp;
|
pub mod folders;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
|
pub mod mcp;
|
||||||
pub mod project_boot;
|
pub mod project_boot;
|
||||||
|
pub mod system_settings;
|
||||||
|
pub mod terminal;
|
||||||
|
pub mod version_control;
|
||||||
pub mod web_server;
|
pub mod web_server;
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ pub async fn start_web_server(
|
|||||||
emitter: crate::web::event_bridge::EventEmitter::Tauri(app.clone()),
|
emitter: crate::web::event_bridge::EventEmitter::Tauri(app.clone()),
|
||||||
data_dir: app.path().app_data_dir().unwrap_or_default(),
|
data_dir: app.path().app_data_dir().unwrap_or_default(),
|
||||||
web_server_state: WebServerState::new(), // placeholder; not used by handlers
|
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);
|
let router = router::build_router(app_state, token.clone(), static_dir);
|
||||||
|
|||||||
@@ -180,6 +180,19 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
|
|||||||
.route("/start_web_server", post(handlers::web_server::start_web_server))
|
.route("/start_web_server", post(handlers::web_server::start_web_server))
|
||||||
.route("/stop_web_server", post(handlers::web_server::stop_web_server))
|
.route("/stop_web_server", post(handlers::web_server::stop_web_server))
|
||||||
.route("/check_app_update", post(handlers::web_server::check_app_update))
|
.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 ───
|
// ─── Terminal ───
|
||||||
.route("/terminal_spawn", post(handlers::terminal::terminal_spawn))
|
.route("/terminal_spawn", post(handlers::terminal::terminal_spawn))
|
||||||
.route("/terminal_write", post(handlers::terminal::terminal_write))
|
.route("/terminal_write", post(handlers::terminal::terminal_write))
|
||||||
|
|||||||
5
src/app/settings/chat-channels/page.tsx
Normal file
5
src/app/settings/chat-channels/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ChatChannelSettings } from "@/components/settings/chat-channel-settings"
|
||||||
|
|
||||||
|
export default function SettingsChatChannelsPage() {
|
||||||
|
return <ChatChannelSettings />
|
||||||
|
}
|
||||||
245
src/components/settings/add-chat-channel-dialog.tsx
Normal file
245
src/components/settings/add-chat-channel-dialog.tsx
Normal file
@@ -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<string | null>(null)
|
||||||
|
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [channelType, setChannelType] = useState<ChannelType>("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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("addChannel")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium">{t("channelName")}</label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("channelNamePlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium">{t("channelType")}</label>
|
||||||
|
<Select
|
||||||
|
value={channelType}
|
||||||
|
onValueChange={(v) => setChannelType(v as ChannelType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="telegram">Telegram</SelectItem>
|
||||||
|
<SelectItem value="lark">
|
||||||
|
{t("lark")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{channelType === "lark" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium">App ID</label>
|
||||||
|
<Input
|
||||||
|
value={appId}
|
||||||
|
onChange={(e) => setAppId(e.target.value)}
|
||||||
|
placeholder="cli_xxxxx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
{channelType === "telegram" ? "Bot Token" : "App Secret"}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
channelType === "telegram"
|
||||||
|
? "123456:ABC-DEF..."
|
||||||
|
: "xxxxx"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium">Chat ID</label>
|
||||||
|
<Input
|
||||||
|
value={chatId}
|
||||||
|
onChange={(e) => setChatId(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
channelType === "telegram" ? "-100123456789" : "oc_xxxxx"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
{t("dailyReport")}
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
checked={dailyReportEnabled}
|
||||||
|
onCheckedChange={setDailyReportEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dailyReportEnabled && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
{t("dailyReportTime")}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={dailyReportTime}
|
||||||
|
onChange={(e) => setDailyReportTime(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={loading}>
|
||||||
|
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />}
|
||||||
|
{t("create")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
300
src/components/settings/chat-channel-settings.tsx
Normal file
300
src/components/settings/chat-channel-settings.tsx
Normal file
@@ -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<ChatChannelInfo[]>([])
|
||||||
|
const [statuses, setStatuses] = useState<ChannelStatusInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ChatChannelInfo | null>(null)
|
||||||
|
const [actionLoading, setActionLoading] = useState<number | null>(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 (
|
||||||
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
{t("loading")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto">
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
<section className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-sm font-semibold">{t("sectionTitle")}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("sectionDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
{t("addChannel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{channels.length === 0 ? (
|
||||||
|
<section className="rounded-xl border bg-card p-8 text-center">
|
||||||
|
<MessageCircle className="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("noChannels")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="space-y-2">
|
||||||
|
{channels.map((ch) => {
|
||||||
|
const status = getChannelStatus(ch.id)
|
||||||
|
const isConnected = status === "connected"
|
||||||
|
const isLoading = actionLoading === ch.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ch.id}
|
||||||
|
className="rounded-xl border bg-card p-4 flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{ch.name}</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{ch.channel_type}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
|
isConnected
|
||||||
|
? "bg-green-500"
|
||||||
|
: status === "connecting"
|
||||||
|
? "bg-yellow-500 animate-pulse"
|
||||||
|
: status === "error"
|
||||||
|
? "bg-red-500"
|
||||||
|
: "bg-gray-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
{ch.daily_report_enabled && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("dailyReport")}: {ch.daily_report_time || "18:00"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={ch.enabled}
|
||||||
|
onCheckedChange={() => handleToggleEnabled(ch)}
|
||||||
|
/>
|
||||||
|
{isConnected ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => handleDisconnect(ch.id)}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PowerOff className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isLoading || !ch.enabled}
|
||||||
|
onClick={() => handleConnect(ch.id)}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Power className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => handleTest(ch.id)}
|
||||||
|
>
|
||||||
|
<TestTube className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteTarget(ch)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddChatChannelDialog
|
||||||
|
open={addDialogOpen}
|
||||||
|
onOpenChange={setAddDialogOpen}
|
||||||
|
onChannelAdded={loadChannels}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("deleteConfirmMessage")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete}>
|
||||||
|
{t("delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
Globe,
|
Globe,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
|
MessageCircle,
|
||||||
Palette,
|
Palette,
|
||||||
PlugZap,
|
PlugZap,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -33,6 +34,7 @@ interface SettingsNavItem {
|
|||||||
| "skills"
|
| "skills"
|
||||||
| "shortcuts"
|
| "shortcuts"
|
||||||
| "version_control"
|
| "version_control"
|
||||||
|
| "chat_channels"
|
||||||
| "system"
|
| "system"
|
||||||
| "web_service"
|
| "web_service"
|
||||||
icon: ComponentType<{ className?: string }>
|
icon: ComponentType<{ className?: string }>
|
||||||
@@ -69,6 +71,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
|
|||||||
labelKey: "version_control",
|
labelKey: "version_control",
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/settings/chat-channels",
|
||||||
|
labelKey: "chat_channels",
|
||||||
|
icon: MessageCircle,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/settings/web-service",
|
href: "/settings/web-service",
|
||||||
labelKey: "web_service",
|
labelKey: "web_service",
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
"shortcuts": "Shortcuts",
|
"shortcuts": "Shortcuts",
|
||||||
"version_control": "Version Control",
|
"version_control": "Version Control",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
|
"chat_channels": "Chat Channels",
|
||||||
"web_service": "Web Service"
|
"web_service": "Web Service"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1670,5 +1671,36 @@
|
|||||||
"emptyDirectory": "This directory is empty",
|
"emptyDirectory": "This directory is empty",
|
||||||
"errorLoadingDir": "Failed to load directory",
|
"errorLoadingDir": "Failed to load directory",
|
||||||
"permissionDenied": "Permission denied"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
"shortcuts": "快捷键",
|
"shortcuts": "快捷键",
|
||||||
"version_control": "版本控制",
|
"version_control": "版本控制",
|
||||||
"system": "系统",
|
"system": "系统",
|
||||||
|
"chat_channels": "消息渠道",
|
||||||
"web_service": "Web 服务"
|
"web_service": "Web 服务"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1670,5 +1671,36 @@
|
|||||||
"emptyDirectory": "此目录为空",
|
"emptyDirectory": "此目录为空",
|
||||||
"errorLoadingDir": "加载目录失败",
|
"errorLoadingDir": "加载目录失败",
|
||||||
"permissionDenied": "权限不足"
|
"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": "创建"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ import type {
|
|||||||
McpMarketplaceProvider,
|
McpMarketplaceProvider,
|
||||||
McpMarketplaceItem,
|
McpMarketplaceItem,
|
||||||
McpMarketplaceServerDetail,
|
McpMarketplaceServerDetail,
|
||||||
|
ChatChannelInfo,
|
||||||
|
ChannelStatusInfo,
|
||||||
|
ChatChannelMessageLog,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
export async function listConversations(params?: {
|
export async function listConversations(params?: {
|
||||||
@@ -1304,3 +1307,98 @@ export async function stopWebServer(): Promise<void> {
|
|||||||
export async function getWebServerStatus(): Promise<WebServerInfo | null> {
|
export async function getWebServerStatus(): Promise<WebServerInfo | null> {
|
||||||
return getTransport().call("get_web_server_status")
|
return getTransport().call("get_web_server_status")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Chat Channels ───
|
||||||
|
|
||||||
|
export async function listChatChannels(): Promise<ChatChannelInfo[]> {
|
||||||
|
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<ChatChannelInfo> {
|
||||||
|
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<ChatChannelInfo> {
|
||||||
|
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<void> {
|
||||||
|
return getTransport().call("delete_chat_channel", { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveChatChannelToken(
|
||||||
|
channelId: number,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return getTransport().call("save_chat_channel_token", { channelId, token })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChatChannelHasToken(
|
||||||
|
channelId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return getTransport().call("get_chat_channel_has_token", { channelId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChatChannelToken(
|
||||||
|
channelId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return getTransport().call("delete_chat_channel_token", { channelId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectChatChannel(id: number): Promise<void> {
|
||||||
|
return getTransport().call("connect_chat_channel", { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectChatChannel(id: number): Promise<void> {
|
||||||
|
return getTransport().call("disconnect_chat_channel", { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testChatChannel(id: number): Promise<void> {
|
||||||
|
return getTransport().call("test_chat_channel", { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChatChannelStatus(): Promise<ChannelStatusInfo[]> {
|
||||||
|
return getTransport().call("get_chat_channel_status")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listChatChannelMessages(params: {
|
||||||
|
channelId: number
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}): Promise<ChatChannelMessageLog[]> {
|
||||||
|
return getTransport().call("list_chat_channel_messages", {
|
||||||
|
channelId: params.channelId,
|
||||||
|
limit: params.limit ?? null,
|
||||||
|
offset: params.offset ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -841,3 +841,44 @@ export interface PreflightResult {
|
|||||||
passed: boolean
|
passed: boolean
|
||||||
checks: CheckItem[]
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user