add: 移除除了 desktop 以外的所有版本

This commit is contained in:
2026-04-26 12:46:58 +08:00
parent bb6a12cd41
commit ce10295e89
87 changed files with 114 additions and 16553 deletions
+6 -123
View File
@@ -386,7 +386,7 @@ dependencies = [
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.28.0",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -615,12 +615,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -866,7 +860,6 @@ dependencies = [
"flate2",
"futures",
"futures-util",
"image",
"include_dir",
"junction",
"keyring",
@@ -874,9 +867,6 @@ dependencies = [
"mac-notification-sys",
"notify",
"portable-pty",
"prost",
"qrcode",
"rand 0.8.5",
"regex",
"reqwest 0.12.28",
"sacp",
@@ -897,7 +887,6 @@ dependencies = [
"tauri-plugin-window-state",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.26.2",
"toml 0.8.2",
"tower-http",
"urlencoding",
@@ -2476,7 +2465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png 0.17.16",
"png",
]
[[package]]
@@ -2587,19 +2576,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
name = "include_dir"
version = "0.7.4"
@@ -3249,16 +3225,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -3274,7 +3240,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation",
"once_cell",
"png 0.17.16",
"png",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -4182,19 +4148,6 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.10.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -4373,29 +4326,6 @@ dependencies = [
"yansi",
]
[[package]]
name = "prost"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
@@ -4416,21 +4346,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
dependencies = [
"image",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -6360,7 +6275,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png 0.17.16",
"png",
"proc-macro2",
"quote",
"semver",
@@ -6856,20 +6771,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
"native-tls",
"tokio",
"tokio-native-tls",
"tungstenite 0.26.2",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
@@ -6879,7 +6780,7 @@ dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite 0.28.0",
"tungstenite",
]
[[package]]
@@ -7111,7 +7012,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png 0.17.16",
"png",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -7123,24 +7024,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"native-tls",
"rand 0.9.2",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.28.0"
-9
View File
@@ -34,10 +34,6 @@ tauri-runtime = [
name = "codeg"
path = "src/main.rs"
[[bin]]
name = "codeg-server"
path = "src/bin/codeg_server.rs"
required-features = []
[build-dependencies]
tauri-build = { version = "2", features = [], optional = true }
@@ -78,12 +74,7 @@ which = "7"
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["fs", "cors"] }
tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
futures-util = "0.3"
prost = "0.13"
rand = "0.8"
qrcode = "0.14"
image = { version = "0.25", default-features = false, features = ["png"] }
include_dir = "0.7"
sha2 = "0.10"
-6
View File
@@ -2,7 +2,6 @@ use std::path::PathBuf;
use std::sync::Arc;
use crate::acp::manager::ConnectionManager;
use crate::chat_channel::manager::ChatChannelManager;
use crate::db::AppDatabase;
use crate::terminal::manager::TerminalManager;
use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster};
@@ -16,7 +15,6 @@ pub struct AppState {
pub emitter: EventEmitter,
pub data_dir: PathBuf,
pub web_server_state: WebServerState,
pub chat_channel_manager: ChatChannelManager,
}
pub fn default_connection_manager() -> ConnectionManager {
@@ -26,7 +24,3 @@ pub fn default_connection_manager() -> ConnectionManager {
pub fn default_terminal_manager() -> TerminalManager {
TerminalManager::new()
}
pub fn default_chat_channel_manager() -> ChatChannelManager {
ChatChannelManager::new()
}
-144
View File
@@ -1,144 +0,0 @@
use std::path::PathBuf;
use std::sync::Arc;
use codeg_lib::app_state::AppState;
use codeg_lib::web::event_bridge::{EventEmitter, WebEventBroadcaster};
use codeg_lib::web::{
find_static_dir_standalone, generate_random_token, get_local_addresses, WebServerState,
};
fn main() {
// Support --version flag
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("{}", env!("CARGO_PKG_VERSION"));
return;
}
// PATH initialisation MUST happen before the tokio runtime is created.
// std::env::set_var is not thread-safe (unsafe in Rust edition 2024);
// #[tokio::main] would spawn worker threads before we reach this point.
codeg_lib::process::ensure_node_in_path();
codeg_lib::process::ensure_user_npm_prefix_in_path();
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("Failed to build tokio runtime")
.block_on(async_main());
}
async fn async_main() {
// Sweep stale ACP binary cache trash (rename-aside fallback artifacts).
// Detached OS thread: cannot block startup, panics are caught and dropped,
// errors are silenced, no subprocesses spawned.
std::thread::spawn(|| {
let _ = std::panic::catch_unwind(|| {
codeg_lib::sweep_acp_binary_trash();
});
});
let port: u16 = std::env::var("CODEG_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3080);
let host = std::env::var("CODEG_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
let token = std::env::var("CODEG_TOKEN").unwrap_or_else(|_| generate_random_token());
let data_dir = std::env::var("CODEG_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| default_data_dir());
let static_dir_env = std::env::var("CODEG_STATIC_DIR").ok();
let static_dir = find_static_dir_standalone(static_dir_env.as_deref());
let app_version = env!("CARGO_PKG_VERSION");
eprintln!("[SERVER] codeg-server v{}", app_version);
eprintln!("[SERVER] Data directory: {}", data_dir.display());
eprintln!("[SERVER] Static directory: {}", static_dir.display());
// Initialize database
let db = codeg_lib::db::init_database(&data_dir, app_version)
.await
.expect("Failed to initialize database");
// Create shared broadcaster
let broadcaster = Arc::new(WebEventBroadcaster::new());
let emitter = EventEmitter::WebOnly(broadcaster.clone());
// Build AppState
let state = Arc::new(AppState {
db,
connection_manager: codeg_lib::app_state::default_connection_manager(),
terminal_manager: codeg_lib::app_state::default_terminal_manager(),
event_broadcaster: broadcaster,
emitter,
data_dir,
web_server_state: WebServerState::new(),
chat_channel_manager: codeg_lib::app_state::default_chat_channel_manager(),
});
// Install bundled expert skills into the central store
// (`~/.codeg/skills/`). Runs in the background; failures are logged
// but non-fatal.
tokio::spawn(async move {
let report = codeg_lib::commands::experts::ensure_central_experts_installed().await;
if !report.errors.is_empty() {
eprintln!(
"[Experts] install finished with {} error(s): {:?}",
report.errors.len(),
report.errors
);
} else {
eprintln!(
"[Experts] install ok: installed={} updated={} pending_review={}",
report.installed_count,
report.updated_count,
report.pending_user_review.len()
);
}
});
// 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(),
state.connection_manager.clone_ref(),
state.emitter.clone(),
)
.await;
// Build router
let router = codeg_lib::web::router::build_router(state, token.clone(), static_dir);
// Bind
let addr = format!("{}:{}", host, port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.unwrap_or_else(|e| {
eprintln!("[SERVER] Failed to bind {}: {}", addr, e);
std::process::exit(1);
});
let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
let addresses = get_local_addresses(actual_port);
eprintln!("[SERVER] Token: {}", token);
eprintln!("[SERVER] Listening on:");
for addr in &addresses {
eprintln!(" {}", addr);
}
// Start serving
if let Err(e) = axum::serve(listener, router).await {
eprintln!("[SERVER] Server error: {}", e);
std::process::exit(1);
}
}
fn default_data_dir() -> PathBuf {
dirs::data_dir()
.map(|d| d.join("codeg"))
.unwrap_or_else(|| PathBuf::from(".codeg-data"))
}
-672
View File
@@ -1,672 +0,0 @@
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,
created_at: Instant,
}
/// TTL for partial message reassembly entries. Prevents unbounded memory growth
/// if a multi-part message never completes (network issue, Lark SDK bug, etc).
const PARTIAL_MSG_TTL_SECS: u64 = 60;
// ── 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::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_default(),
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();
let mut last_partial_cleanup = Instant::now();
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);
// Evict stale partial messages to prevent unbounded memory growth
if last_partial_cleanup.elapsed() > Duration::from_secs(PARTIAL_MSG_TTL_SECS) {
partial_msgs.retain(|_, pm| pm.created_at.elapsed() < Duration::from_secs(PARTIAL_MSG_TTL_SECS));
last_partial_cleanup = Instant::now();
}
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,
created_at: Instant::now(),
});
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;
}
// Group chat filtering: only process if bot is mentioned
let chat_type = event
.pointer("/event/message/chat_type")
.and_then(|v| v.as_str())
.unwrap_or("p2p");
if chat_type == "group" {
let mentions = event
.pointer("/event/message/mentions")
.and_then(|v| v.as_array());
if mentions.is_none() || mentions.unwrap().is_empty() {
return; // No mentions in group chat, ignore
}
}
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;
}
// Strip mention placeholders (e.g. "@_user_1") from text
let clean_text = strip_lark_mentions(&text, event);
if clean_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, clean_text);
let _ = command_tx
.send(IncomingCommand {
channel_id,
sender_id,
command_text: clean_text,
metadata: event.clone(),
})
.await;
}
}
/// Strip Lark mention placeholders (e.g. `@_user_1`) from the message text.
fn strip_lark_mentions(text: &str, event: &serde_json::Value) -> String {
let mut result = text.to_string();
if let Some(mentions) = event
.pointer("/event/message/mentions")
.and_then(|v| v.as_array())
{
for mention in mentions {
if let Some(key) = mention.get("key").and_then(|v| v.as_str()) {
result = result.replace(key, "");
}
}
}
result.trim().to_string()
}
/// 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,
})
}
@@ -1,65 +0,0 @@
pub mod lark;
pub mod telegram;
pub mod weixin;
use super::error::ChatChannelError;
use super::traits::ChatChannelBackend;
use super::types::*;
/// Factory function to create a backend instance from channel type, config, and token.
/// Eliminates duplicated match blocks across connect, test, and auto-connect paths.
pub fn create_backend(
channel_id: i32,
channel_type: ChannelType,
config: &serde_json::Value,
token: String,
) -> Result<Box<dyn ChatChannelBackend>, ChatChannelError> {
match channel_type {
ChannelType::Telegram => {
let cfg: TelegramConfig = serde_json::from_value(config.clone()).map_err(|e| {
ChatChannelError::ConfigurationInvalid(format!("Invalid Telegram config: {e}"))
})?;
if cfg.chat_id.is_empty() {
return Err(ChatChannelError::ConfigurationInvalid(
"chat_id is required".into(),
));
}
Ok(Box::new(telegram::TelegramBackend::new(
channel_id,
token,
cfg.chat_id,
)))
}
ChannelType::Weixin => {
let cfg: WeixinConfig = serde_json::from_value(config.clone()).map_err(|e| {
ChatChannelError::ConfigurationInvalid(format!("Invalid Weixin config: {e}"))
})?;
if cfg.base_url.is_empty() {
return Err(ChatChannelError::ConfigurationInvalid(
"base_url is required".into(),
));
}
Ok(Box::new(weixin::WeixinBackend::new(
channel_id,
token,
cfg.base_url,
)))
}
ChannelType::Lark => {
let cfg: LarkConfig = serde_json::from_value(config.clone()).map_err(|e| {
ChatChannelError::ConfigurationInvalid(format!("Invalid Lark config: {e}"))
})?;
if cfg.app_id.is_empty() || cfg.chat_id.is_empty() {
return Err(ChatChannelError::ConfigurationInvalid(
"app_id and chat_id are required".into(),
));
}
Ok(Box::new(lark::LarkBackend::new(
channel_id,
cfg.app_id,
token,
cfg.chat_id,
)))
}
}
}
@@ -1,360 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
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::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.build()
.unwrap_or_default(),
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 fn send_text(
&self,
text: &str,
parse_mode: Option<&str>,
) -> Result<SentMessageId, ChatChannelError> {
let mut body = serde_json::json!({
"chat_id": self.chat_id,
"text": text,
});
if let Some(mode) = parse_mode {
body["parse_mode"] = serde_json::Value::String(mode.to_string());
}
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()))?;
if result.get("ok").and_then(|v| v.as_bool()) != Some(true) {
let desc = result
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Err(ChatChannelError::SendFailed(desc.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_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 and extract bot username for group @mention filtering
let resp = self
.client
.get(self.api_url("getMe"))
.send()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
let me_body: serde_json::Value = resp
.json()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
if me_body.get("ok").and_then(|v| v.as_bool()) != Some(true) {
*self.status.lock().await = ChannelConnectionStatus::Error;
return Err(ChatChannelError::AuthenticationFailed(
"Invalid bot token".to_string(),
));
}
let bot_username = me_body
.pointer("/result/username")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_lowercase();
*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) => {
// Recover from error state after successful poll
{
let mut s = status.lock().await;
if *s == ChannelConnectionStatus::Error {
*s = ChannelConnectionStatus::Connected;
}
}
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(updates) = body.get("result").and_then(|r| r.as_array()) {
if !updates.is_empty() {
eprintln!("[Telegram] got {} update(s)", updates.len());
}
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())
{
// Group chat filtering: only process if @bot is mentioned
let chat_type = update
.pointer("/message/chat/type")
.and_then(|v| v.as_str())
.unwrap_or("private");
if (chat_type == "group" || chat_type == "supergroup")
&& !bot_username.is_empty()
{
let at_bot = format!("@{}", bot_username);
if !text.to_lowercase().contains(&at_bot) {
eprintln!("[Telegram] skipped group msg without @bot: {text}");
continue;
}
}
// Strip @bot_username from command text (case-insensitive)
let clean_text = strip_bot_mention(text, &bot_username);
let sender_id = update
.pointer("/message/from/id")
.and_then(|i| i.as_i64())
.map(|i| i.to_string())
.unwrap_or_default();
eprintln!("[Telegram] dispatching: {clean_text}");
let send_result = command_tx
.send(IncomingCommand {
channel_id,
sender_id,
command_text: clean_text,
metadata: update.clone(),
})
.await;
if let Err(e) = send_result {
eprintln!("[Telegram] command_tx.send failed: {e}");
}
} else {
eprintln!("[Telegram] update without /message/text");
}
}
}
} else {
eprintln!("[Telegram] failed to parse response body");
}
}
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::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> {
self.send_text(text, None).await
}
async fn send_rich_message(
&self,
message: &RichMessage,
) -> Result<SentMessageId, ChatChannelError> {
let markdown_text = format_telegram_markdown(message);
let result = self.send_text(&markdown_text, Some("MarkdownV2")).await;
match result {
Ok(id) => Ok(id),
Err(e) => {
// MarkdownV2 failed — fall back to plain text
eprintln!("[Telegram] MarkdownV2 send failed: {e}, retrying as plain text");
let plain_text = message.to_plain_text();
self.send_text(&plain_text, None).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()))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
if body.get("ok").and_then(|v| v.as_bool()) == Some(true) {
Ok(())
} else {
let desc = body
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("Invalid bot token");
Err(ChatChannelError::AuthenticationFailed(desc.to_string()))
}
}
}
/// Strip `@bot_username` from text (case-insensitive).
/// Handles Telegram convention: `/command@botname args` → `/command args`
fn strip_bot_mention(text: &str, bot_username: &str) -> String {
if bot_username.is_empty() {
return text.to_string();
}
let at_bot = format!("@{}", bot_username);
let text_lower = text.to_lowercase();
let at_bot_lower = at_bot.to_lowercase();
if let Some(pos) = text_lower.find(&at_bot_lower) {
let mut result = String::with_capacity(text.len());
result.push_str(&text[..pos]);
result.push_str(&text[pos + at_bot.len()..]);
result.trim().to_string()
} else {
text.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 {
// Backslash must be escaped first to avoid double-escaping
text.replace('\\', "\\\\")
.replace('_', "\\_")
.replace('*', "\\*")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('(', "\\(")
.replace(')', "\\)")
.replace('~', "\\~")
.replace('`', "\\`")
.replace('>', "\\>")
.replace('#', "\\#")
.replace('+', "\\+")
.replace('-', "\\-")
.replace('=', "\\=")
.replace('|', "\\|")
.replace('{', "\\{")
.replace('}', "\\}")
.replace('.', "\\.")
.replace('!', "\\!")
}
@@ -1,798 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use rand::Rng;
use reqwest::header::{HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, Mutex};
use crate::chat_channel::error::ChatChannelError;
use crate::chat_channel::traits::ChatChannelBackend;
use crate::chat_channel::types::*;
const ILINK_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
const ILINK_CHANNEL_VERSION: &str = "1.0.2";
/// Maximum number of messages buffered while context_token is expired.
const MAX_PENDING_MESSAGES: usize = 50;
/// Shared HTTP client for QR code auth requests (avoids re-creating TLS state).
fn qr_client() -> reqwest::Client {
use std::sync::OnceLock;
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT
.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.build()
.unwrap_or_default()
})
.clone()
}
// ── QR code auth types (public, used by commands) ──
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeixinQrcodeInfo {
pub qrcode_id: String,
pub qrcode_img_content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeixinQrcodeStatus {
pub status: String,
/// bot_token and base_url are consumed by the _core command layer and
/// stripped before the response reaches the frontend.
#[serde(skip_serializing_if = "Option::is_none")]
pub bot_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
}
/// Frontend-safe subset of [`WeixinQrcodeStatus`] — no credentials.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeixinQrcodeStatusPublic {
pub status: String,
}
struct SendRequest<'a> {
client: &'a reqwest::Client,
base_url: &'a str,
bot_token: &'a str,
wechat_uin: &'a str,
to_user_id: &'a str,
context_token: &'a str,
text: &'a str,
reply_context: &'a Mutex<Option<WeixinReplyContext>>,
pending_messages: &'a Mutex<Vec<String>>,
}
// ── QR code auth functions (called before backend exists) ──
pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, ChatChannelError> {
let client = qr_client();
let resp = client
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_bot_qrcode"))
.query(&[("bot_type", "3")])
.send()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR code request failed: {e}")))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR code parse failed: {e}")))?;
let qrcode_id = body
.get("qrcode")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let raw_img = body
.get("qrcode_img_content")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if qrcode_id.is_empty() {
return Err(ChatChannelError::ConnectionFailed(
"Empty qrcode in response".into(),
));
}
// If the image content is a URL, try to fetch the actual image bytes.
// If the URL points to an HTML SPA (which renders the QR code via JS),
// generate the QR code ourselves — the SPA simply encodes the page URL.
let qrcode_img_content = if raw_img.starts_with("http://") || raw_img.starts_with("https://") {
match fetch_image_as_data_uri(&client, &raw_img).await {
Ok(data_uri) => data_uri,
Err(_) => {
eprintln!("[Weixin] URL is an SPA page, generating QR code from URL");
generate_qrcode_data_uri(&raw_img)?
}
}
} else {
raw_img
};
Ok(WeixinQrcodeInfo {
qrcode_id,
qrcode_img_content,
})
}
/// Fetch an image from a URL and return it as a `data:<mime>;base64,...` string.
///
/// Returns an error if the URL points to an HTML page (SPA) rather than a
/// raw image — the caller will generate a QR code from the URL instead.
async fn fetch_image_as_data_uri(
client: &reqwest::Client,
url: &str,
) -> Result<String, ChatChannelError> {
let resp = client
.get(url)
.header(
reqwest::header::USER_AGENT,
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
)
.header(reqwest::header::REFERER, ILINK_BASE_URL)
.send()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(format!("Image fetch failed: {e}")))?;
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("image/png")
.to_string();
if content_type.contains("text/html") || content_type.contains("text/plain") {
return Err(ChatChannelError::ConnectionFailed(
"QR code URL is an SPA page".into(),
));
}
let bytes = resp
.bytes()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(format!("Image read failed: {e}")))?;
if bytes.is_empty() {
return Err(ChatChannelError::ConnectionFailed(
"Empty image response".into(),
));
}
let b64 = B64.encode(&bytes);
let mime = content_type.split(';').next().unwrap_or("image/png").trim();
Ok(format!("data:{mime};base64,{b64}"))
}
/// Generate a QR code image encoding the given text and return as a PNG data URI.
///
/// The iLink QR page is a SPA that renders `window.location.href` as a QR code.
/// We replicate that logic server-side so the frontend can display it directly.
fn generate_qrcode_data_uri(content: &str) -> Result<String, ChatChannelError> {
use image::{codecs::png::PngEncoder, ImageEncoder, Luma};
use qrcode::QrCode;
let code = QrCode::new(content.as_bytes()).map_err(|e| {
ChatChannelError::ConnectionFailed(format!("QR code generation failed: {e}"))
})?;
let img = code
.render::<Luma<u8>>()
.quiet_zone(true)
.min_dimensions(250, 250)
.build();
let (w, h) = (img.width(), img.height());
let mut png_buf: Vec<u8> = Vec::new();
PngEncoder::new(&mut png_buf)
.write_image(img.as_raw(), w, h, image::ExtendedColorType::L8)
.map_err(|e| ChatChannelError::ConnectionFailed(format!("PNG encoding failed: {e}")))?;
let b64 = B64.encode(&png_buf);
Ok(format!("data:image/png;base64,{b64}"))
}
pub async fn weixin_check_qrcode(qrcode: &str) -> Result<WeixinQrcodeStatus, ChatChannelError> {
let client = qr_client();
let resp = client
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status"))
.query(&[("qrcode", qrcode)])
.send()
.await
.map_err(|e| {
ChatChannelError::ConnectionFailed(format!("QR status request failed: {e}"))
})?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR status parse failed: {e}")))?;
let status = body
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("waiting")
.to_string();
let bot_token = body
.get("bot_token")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let base_url = body
.get("baseurl")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(WeixinQrcodeStatus {
status,
bot_token,
base_url,
})
}
// ── Backend implementation ──
struct WeixinReplyContext {
to_user_id: String,
context_token: String,
expired: bool,
}
pub struct WeixinBackend {
bot_token: String,
base_url: String,
client: reqwest::Client,
status: Arc<Mutex<ChannelConnectionStatus>>,
channel_id: i32,
shutdown_tx: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
reply_context: Arc<Mutex<Option<WeixinReplyContext>>>,
/// Messages that failed due to expired context_token, resend on next refresh.
pending_messages: Arc<Mutex<Vec<String>>>,
/// Stable X-WECHAT-UIN value for this backend instance.
wechat_uin: String,
}
impl WeixinBackend {
pub fn new(channel_id: i32, bot_token: String, base_url: String) -> Self {
let uin_raw = rand::thread_rng().gen::<u32>().to_string();
let wechat_uin = B64.encode(uin_raw.as_bytes());
Self {
bot_token,
base_url,
client: reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(45))
.build()
.unwrap_or_default(),
status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)),
channel_id,
shutdown_tx: Arc::new(Mutex::new(None)),
reply_context: Arc::new(Mutex::new(None)),
pending_messages: Arc::new(Mutex::new(Vec::new())),
wechat_uin,
}
}
fn build_headers(bot_token: &str, wechat_uin: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
headers.insert(
"AuthorizationType",
HeaderValue::from_static("ilink_bot_token"),
);
if let Ok(val) = HeaderValue::from_str(wechat_uin) {
headers.insert("X-WECHAT-UIN", val);
}
let bearer = format!("Bearer {bot_token}");
if let Ok(val) = HeaderValue::from_str(&bearer) {
headers.insert("Authorization", val);
}
headers
}
/// Build the JSON body for the iLink sendmessage API.
fn build_send_body(to_user_id: &str, context_token: &str, text: &str) -> serde_json::Value {
serde_json::json!({
"msg": {
"from_user_id": "",
"to_user_id": to_user_id,
"client_id": format!("codeg-{}", uuid::Uuid::new_v4()),
"message_type": 2,
"message_state": 2,
"context_token": context_token,
"item_list": [{
"type": 1,
"text_item": { "text": text }
}]
},
"base_info": { "channel_version": ILINK_CHANNEL_VERSION }
})
}
/// Send a message via the iLink API and handle the response.
/// Returns `Ok(true)` if sent, `Ok(false)` if buffered due to expired context.
async fn do_send(req: SendRequest<'_>) -> Result<bool, ChatChannelError> {
let body = Self::build_send_body(req.to_user_id, req.context_token, req.text);
let url = format!("{}/ilink/bot/sendmessage", req.base_url);
let resp = req
.client
.post(&url)
.headers(Self::build_headers(req.bot_token, req.wechat_uin))
.json(&body)
.send()
.await
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
let status_code = resp.status();
let resp_text = resp.text().await.unwrap_or_default();
if !status_code.is_success() {
return Err(ChatChannelError::SendFailed(format!(
"HTTP {status_code}: {resp_text}"
)));
}
// Check for ret errors in response (e.g. -2 = context expired)
if let Ok(resp_json) = serde_json::from_str::<serde_json::Value>(&resp_text) {
if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) {
if ret != 0 {
let errmsg = resp_json
.get("errmsg")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
eprintln!("[Weixin] sendmessage ret={ret}, errmsg={errmsg}");
if ret == -2 {
// Context token expired — mark stale and buffer
if let Some(ref mut c) = *req.reply_context.lock().await {
c.expired = true;
}
let mut buf = req.pending_messages.lock().await;
if buf.len() < MAX_PENDING_MESSAGES {
buf.push(req.text.to_string());
}
eprintln!("[Weixin] context_token expired (ret=-2), buffered message");
return Ok(false);
}
return Err(ChatChannelError::SendFailed(format!("ret={ret}: {errmsg}")));
}
}
}
Ok(true)
}
async fn send_text(&self, text: &str) -> Result<SentMessageId, ChatChannelError> {
// Extract context data under lock, then release
let (to_user_id, context_token, expired) = {
let guard = self.reply_context.lock().await;
let ctx = guard.as_ref().ok_or_else(|| {
ChatChannelError::SendFailed(
"No active WeChat conversation context. A user must message the bot first."
.into(),
)
})?;
(
ctx.to_user_id.clone(),
ctx.context_token.clone(),
ctx.expired,
)
};
// If context is expired, buffer the message for resend on next refresh
if expired {
eprintln!(
"[Weixin] context expired, buffering message (len={})",
text.len()
);
let mut buf = self.pending_messages.lock().await;
if buf.len() < MAX_PENDING_MESSAGES {
buf.push(text.to_string());
} else {
eprintln!("[Weixin] pending buffer full, dropping message");
}
return Ok(SentMessageId(String::new()));
}
eprintln!(
"[Weixin] sendmessage to={to_user_id}, context_token_len={}, text_len={}",
context_token.len(),
text.len()
);
Self::do_send(SendRequest {
client: &self.client,
base_url: &self.base_url,
bot_token: &self.bot_token,
wechat_uin: &self.wechat_uin,
to_user_id: &to_user_id,
context_token: &context_token,
text,
reply_context: &self.reply_context,
pending_messages: &self.pending_messages,
})
.await?;
Ok(SentMessageId(String::new()))
}
}
#[async_trait]
impl ChatChannelBackend for WeixinBackend {
fn channel_type(&self) -> ChannelType {
ChannelType::Weixin
}
async fn start(
&self,
command_tx: mpsc::Sender<IncomingCommand>,
) -> Result<(), ChatChannelError> {
*self.status.lock().await = ChannelConnectionStatus::Connecting;
eprintln!(
"[Weixin] start: base_url={}, token_len={}",
self.base_url,
self.bot_token.len()
);
// Verify auth by doing a quick getupdates with empty cursor
let verify_body = serde_json::json!({
"get_updates_buf": "",
"base_info": { "channel_version": ILINK_CHANNEL_VERSION }
});
let url = format!("{}/ilink/bot/getupdates", self.base_url);
eprintln!("[Weixin] verify POST {url}");
let resp = self
.client
.post(&url)
.headers(Self::build_headers(&self.bot_token, &self.wechat_uin))
.json(&verify_body)
.send()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
let status_code = resp.status();
let resp_text = resp
.text()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
eprintln!("[Weixin] verify response status={status_code}, body={resp_text}");
let verify_result: serde_json::Value = serde_json::from_str(&resp_text)
.map_err(|e| ChatChannelError::ConnectionFailed(format!("JSON parse failed: {e}")))?;
// iLink API auth failures come back as `{"errcode":-14,"errmsg":"session timeout"}`
// (no `ret` field). Treat any non-zero errcode as authentication failure.
if let Some(errcode) = verify_result.get("errcode").and_then(|v| v.as_i64()) {
if errcode != 0 {
let errmsg = verify_result
.get("errmsg")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Err(ChatChannelError::AuthenticationFailed(format!(
"Weixin verification failed (errcode={errcode}): {errmsg}"
)));
}
}
let ret = verify_result.get("ret").and_then(|v| v.as_i64());
// Check for known auth-failure codes
if ret == Some(-14) {
return Err(ChatChannelError::AuthenticationFailed(
"Session expired (ret=-14), please re-authenticate".into(),
));
}
// The iLink API may omit the `ret` field or return non-zero on the first
// call. Always extract the cursor if present — it's needed for polling.
let initial_cursor = verify_result
.get("get_updates_buf")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if let Some(r) = ret {
if r != 0 {
eprintln!(
"[Weixin] verify returned ret={r}, but got cursor len={}",
initial_cursor.len()
);
}
}
*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 base_url = self.base_url.clone();
let wechat_uin = self.wechat_uin.clone();
let channel_id = self.channel_id;
let status = self.status.clone();
let reply_context = self.reply_context.clone();
let pending_messages = self.pending_messages.clone();
tokio::spawn(async move {
let mut cursor = initial_cursor;
let mut consecutive_errors: u32 = 0;
loop {
if *shutdown_rx.borrow() {
break;
}
let body = serde_json::json!({
"get_updates_buf": cursor,
"base_info": { "channel_version": ILINK_CHANNEL_VERSION }
});
let result = tokio::select! {
r = client
.post(format!("{base_url}/ilink/bot/getupdates"))
.headers(WeixinBackend::build_headers(&bot_token, &wechat_uin))
.json(&body)
.send() => r,
_ = shutdown_rx.changed() => break,
};
match result {
Ok(resp) => {
// Recover from error state after successful poll
consecutive_errors = 0;
{
let mut s = status.lock().await;
if *s == ChannelConnectionStatus::Error {
*s = ChannelConnectionStatus::Connected;
}
}
if let Ok(body) = resp.json::<serde_json::Value>().await {
let ret = body.get("ret").and_then(|v| v.as_i64());
// Always update cursor if present
if let Some(new_cursor) =
body.get("get_updates_buf").and_then(|v| v.as_str())
{
if !new_cursor.is_empty() {
cursor = new_cursor.to_string();
}
}
// If ret is explicitly non-zero (not just missing), log it
if let Some(r) = ret {
if r != 0 {
eprintln!("[Weixin] getupdates ret={r}");
}
// Session expired — pause and wait for re-auth
if r == -14 {
eprintln!("[Weixin] session expired (ret=-14), pausing 30s");
*status.lock().await = ChannelConnectionStatus::Error;
tokio::time::sleep(Duration::from_secs(30)).await;
continue;
}
}
// Process messages
if let Some(msgs) = body.get("msgs").and_then(|v| v.as_array()) {
if !msgs.is_empty() {
eprintln!("[Weixin] got {} message(s)", msgs.len());
}
for msg in msgs {
// Only handle user messages (message_type=1),
// skip bot echo (message_type=2)
let msg_type = msg.get("message_type").and_then(|v| v.as_i64());
if msg_type != Some(1) {
continue;
}
// Extract text from type=1 (text) or type=3 (voice-to-text)
let text = msg
.get("item_list")
.and_then(|v| v.as_array())
.and_then(|items| {
items.iter().find_map(|item| {
let t =
item.get("type").and_then(|v| v.as_i64())?;
match t {
1 => item
.pointer("/text_item/text")
.and_then(|v| v.as_str()),
3 => item
.pointer("/voice_item/text")
.and_then(|v| v.as_str()),
_ => None,
}
})
});
let text = match text {
Some(t) if !t.is_empty() => t,
_ => {
eprintln!("[Weixin] skipped non-text message");
continue;
}
};
let from_user_id = msg
.get("from_user_id")
.and_then(|v| v.as_str())
.unwrap_or_default();
let context_token = msg
.get("context_token")
.and_then(|v| v.as_str())
.unwrap_or_default();
// Store reply context for outbound messages
// Single lock scope to avoid TOCTOU
if !from_user_id.is_empty() && !context_token.is_empty() {
let was_expired = {
let mut guard = reply_context.lock().await;
let was =
guard.as_ref().map(|c| c.expired).unwrap_or(false);
*guard = Some(WeixinReplyContext {
to_user_id: from_user_id.to_string(),
context_token: context_token.to_string(),
expired: false,
});
was
};
// Resend buffered messages with fresh context
if was_expired {
let buffered: Vec<String> =
pending_messages.lock().await.drain(..).collect();
if !buffered.is_empty() {
eprintln!(
"[Weixin] context refreshed, resending {} buffered message(s)",
buffered.len()
);
for pending_text in &buffered {
let ok = WeixinBackend::do_send(SendRequest {
client: &client,
base_url: &base_url,
bot_token: &bot_token,
wechat_uin: &wechat_uin,
to_user_id: from_user_id,
context_token,
text: pending_text,
reply_context: &reply_context,
pending_messages: &pending_messages,
})
.await;
if let Err(e) = ok {
eprintln!("[Weixin] resend error: {e}");
// Re-buffer remaining on hard error
let mut buf = pending_messages.lock().await;
if buf.len() < MAX_PENDING_MESSAGES {
buf.push(pending_text.clone());
}
}
// If do_send returned Ok(false), it
// already re-buffered internally.
}
}
}
}
eprintln!("[Weixin] dispatching: {text}");
let send_result = command_tx
.send(IncomingCommand {
channel_id,
sender_id: from_user_id.to_string(),
command_text: text.to_string(),
metadata: msg.clone(),
})
.await;
if let Err(e) = send_result {
eprintln!("[Weixin] command_tx.send failed: {e}");
}
}
}
} else {
eprintln!("[Weixin] failed to parse response body");
}
}
Err(e) => {
consecutive_errors += 1;
eprintln!("[Weixin] polling error ({consecutive_errors}): {e}");
*status.lock().await = ChannelConnectionStatus::Error;
// Exponential backoff: 5s, 10s, 20s, capped at 30s
let delay =
std::cmp::min(5 * 2u64.saturating_pow(consecutive_errors - 1), 30);
tokio::time::sleep(Duration::from_secs(delay)).await;
}
}
}
*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> {
self.send_text(text).await
}
async fn send_rich_message(
&self,
message: &RichMessage,
) -> Result<SentMessageId, ChatChannelError> {
let plain_text = message.to_plain_text();
self.send_text(&plain_text).await
}
async fn test_connection(&self) -> Result<(), ChatChannelError> {
let body = serde_json::json!({
"get_updates_buf": "",
"base_info": { "channel_version": ILINK_CHANNEL_VERSION }
});
let url = format!("{}/ilink/bot/getupdates", self.base_url);
let resp = self
.client
.post(&url)
.headers(Self::build_headers(&self.bot_token, &self.wechat_uin))
.json(&body)
.send()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
let status_code = resp.status();
let resp_text = resp
.text()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
eprintln!("[Weixin] test_connection: status={status_code}, body={resp_text}");
let resp_json: serde_json::Value = serde_json::from_str(&resp_text)
.map_err(|e| ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}")))?;
if !status_code.is_success() {
return Err(ChatChannelError::AuthenticationFailed(format!(
"HTTP {status_code}"
)));
}
// Check for known auth-failure codes
if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) {
if ret == -14 {
return Err(ChatChannelError::AuthenticationFailed(
"Session expired (ret=-14)".into(),
));
}
}
Ok(())
}
}
@@ -1,234 +0,0 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use sea_orm::DatabaseConnection;
use tokio::sync::{mpsc, Mutex};
use tokio::task::JoinHandle;
use super::command_handlers;
use super::i18n::{self, Lang};
use super::manager::ChatChannelManager;
use super::session_bridge::SessionBridge;
use super::session_commands;
use super::types::IncomingCommand;
use crate::acp::manager::ConnectionManager;
use crate::db::service::{app_metadata_service, chat_channel_message_log_service};
use crate::web::event_bridge::EventEmitter;
const COMMAND_PREFIX_KEY: &str = "chat_command_prefix";
const DEFAULT_COMMAND_PREFIX: &str = "/";
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
/// How often to refresh cached config from DB.
const CONFIG_CACHE_TTL_SECS: u64 = 30;
struct CommandConfigCache {
prefix: String,
lang: Lang,
last_refresh: Instant,
}
impl CommandConfigCache {
fn new() -> Self {
Self {
prefix: DEFAULT_COMMAND_PREFIX.to_string(),
lang: Lang::default(),
// Force refresh on first use
last_refresh: Instant::now() - Duration::from_secs(CONFIG_CACHE_TTL_SECS + 1),
}
}
async fn refresh_if_needed(&mut self, db: &DatabaseConnection) {
if self.last_refresh.elapsed() < Duration::from_secs(CONFIG_CACHE_TTL_SECS) {
return;
}
if let Ok(Some(val)) = app_metadata_service::get_value(db, COMMAND_PREFIX_KEY).await {
self.prefix = val;
}
if let Ok(Some(val)) = app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY).await {
self.lang = Lang::from_str_lossy(&val);
}
self.last_refresh = Instant::now();
}
}
pub fn spawn_command_dispatcher(
mut command_rx: mpsc::Receiver<IncomingCommand>,
manager: ChatChannelManager,
db_conn: DatabaseConnection,
conn_mgr: ConnectionManager,
emitter: EventEmitter,
bridge: Arc<Mutex<SessionBridge>>,
) -> JoinHandle<()> {
tokio::spawn(async move {
let mut config = CommandConfigCache::new();
while let Some(cmd) = command_rx.recv().await {
let text = cmd.command_text.trim();
eprintln!(
"[ChatChannel] received command from channel={} sender={}: {:?}",
cmd.channel_id, cmd.sender_id, text
);
// Log inbound command
let _ = chat_channel_message_log_service::create_log(
&db_conn,
cmd.channel_id,
"inbound",
"command_query",
text,
"sent",
None,
)
.await;
config.refresh_if_needed(&db_conn).await;
let response = dispatch_command(
text,
&config.prefix,
&db_conn,
&manager,
&conn_mgr,
&emitter,
&bridge,
cmd.channel_id,
&cmd.sender_id,
config.lang,
)
.await;
eprintln!(
"[ChatChannel] dispatch result: title={:?}, body_len={}",
response.title,
response.body.len()
);
// 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) => {
eprintln!(
"[ChatChannel] failed to send response for {:?} to channel {}: {e}",
text, cmd.channel_id
);
("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;
}
})
}
#[allow(clippy::too_many_arguments)]
async fn dispatch_command(
text: &str,
prefix: &str,
db: &DatabaseConnection,
manager: &ChatChannelManager,
conn_mgr: &ConnectionManager,
emitter: &EventEmitter,
bridge: &Arc<Mutex<SessionBridge>>,
channel_id: i32,
sender_id: &str,
lang: Lang,
) -> super::types::RichMessage {
// Strip prefix; if text doesn't start with it, try as follow-up
let without_prefix = match text.strip_prefix(prefix) {
Some(rest) => rest,
None => {
// Check if sender has an active session for follow-up
let has_session = {
let guard = bridge.lock().await;
guard.find_by_sender(channel_id, sender_id).is_some()
};
if has_session {
return session_commands::handle_followup(session_commands::FollowupRequest {
db,
text,
channel_id,
sender_id,
conn_mgr,
bridge,
lang,
prefix,
})
.await;
}
return command_handlers::handle_help(prefix, lang);
}
};
let parts: Vec<&str> = without_prefix.splitn(2, ' ').collect();
let command = parts[0].to_lowercase();
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
match command.as_str() {
// Existing commands
"search" => {
if args.is_empty() {
super::types::RichMessage::info(i18n::search_usage(lang, prefix))
.with_title(i18n::invalid_args_title(lang))
} else {
command_handlers::handle_search(db, args, lang).await
}
}
"today" => command_handlers::handle_today(db, lang).await,
"status" => command_handlers::handle_status(manager, lang).await,
"help" | "start" => command_handlers::handle_help(prefix, lang),
// Session commands
"folder" => {
session_commands::handle_folder(db, args, channel_id, sender_id, lang, prefix).await
}
"agent" => {
session_commands::handle_agent(db, args, channel_id, sender_id, lang, prefix).await
}
"task" | "do" => {
session_commands::handle_task(
db, args, channel_id, sender_id, conn_mgr, emitter, bridge, lang, prefix,
)
.await
}
"sessions" => {
session_commands::handle_sessions(db, channel_id, sender_id, lang, prefix).await
}
"resume" => {
session_commands::handle_resume(
db, args, channel_id, sender_id, conn_mgr, emitter, bridge, lang, prefix,
)
.await
}
"cancel" => {
session_commands::handle_cancel(db, channel_id, sender_id, conn_mgr, bridge, lang).await
}
"approve" => {
let always = args.eq_ignore_ascii_case("always");
session_commands::handle_permission_response(
true, always, db, channel_id, sender_id, conn_mgr, bridge, lang,
)
.await
}
"deny" => {
session_commands::handle_permission_response(
false, false, db, channel_id, sender_id, conn_mgr, bridge, lang,
)
.await
}
_ => super::types::RichMessage::info(i18n::unknown_command(lang, prefix, &command))
.with_title(i18n::unknown_command_title(lang)),
}
}
@@ -1,133 +0,0 @@
use chrono::Utc;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use super::i18n::{self, Lang};
use super::manager::ChatChannelManager;
use super::types::{MessageLevel, RichMessage};
use crate::db::entities::conversation;
pub async fn handle_search(db: &DatabaseConnection, keyword: &str, lang: Lang) -> RichMessage {
let matched = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.filter(conversation::Column::Title.contains(keyword))
.order_by_desc(conversation::Column::CreatedAt)
.limit(10)
.all(db)
.await
{
Ok(rows) => rows,
Err(e) => {
return RichMessage {
title: Some(i18n::query_failed_title(lang).to_string()),
body: e.to_string(),
fields: Vec::new(),
level: MessageLevel::Error,
};
}
};
if matched.is_empty() {
return RichMessage::info(i18n::search_no_results(lang, keyword))
.with_title(i18n::search_results_title(lang));
}
let mut body = String::new();
for conv in &matched {
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
let agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,));
}
RichMessage::info(body.trim_end()).with_title(i18n::search_results_count_title(
lang,
keyword,
matched.len(),
))
}
pub async fn handle_today(db: &DatabaseConnection, lang: Lang) -> 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(i18n::query_failed_title(lang).to_string()),
body: e.to_string(),
fields: Vec::new(),
level: MessageLevel::Error,
};
}
};
if rows.is_empty() {
return RichMessage::info(i18n::no_activity_today(lang))
.with_title(i18n::today_activity_title(lang));
}
// 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 = i18n::total_sessions(lang, rows.len() as u32);
body.push_str(&format!("\n\n{}", i18n::by_agent_label(lang)));
for (agent, count) in &by_agent {
body.push_str(&format!("\n {}", i18n::agent_count(lang, agent, *count)));
}
if !titles.is_empty() {
body.push_str(&format!("\n\n{}", i18n::recent_activity_label(lang)));
for t in &titles {
body.push_str(&format!("\n • {t}"));
}
}
RichMessage::info(body).with_title(i18n::today_activity_date_title(
lang,
&now.format("%Y-%m-%d").to_string(),
))
}
pub async fn handle_status(manager: &ChatChannelManager, lang: Lang) -> RichMessage {
let statuses = manager.get_status().await;
if statuses.is_empty() {
return RichMessage::info(i18n::no_active_channels(lang))
.with_title(i18n::channel_status_title(lang));
}
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(i18n::channel_status_title(lang))
}
pub fn handle_help(prefix: &str, lang: Lang) -> RichMessage {
RichMessage::info(i18n::help_body(lang, prefix)).with_title(i18n::help_title(lang))
}
-39
View File
@@ -1,39 +0,0 @@
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()),
}
}
}
@@ -1,215 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use sea_orm::DatabaseConnection;
use tokio::task::JoinHandle;
use super::i18n::Lang;
use super::manager::ChatChannelManager;
use super::message_formatter;
use super::types::RichMessage;
use crate::db::service::{
app_metadata_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;
/// How often to refresh cached config from DB.
const CONFIG_CACHE_TTL_SECS: u64 = 30;
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
const EVENT_FILTER_KEY: &str = "chat_event_filter";
struct CachedChannel {
id: i32,
event_filter_json: Option<String>,
}
struct EventConfigCache {
lang: Lang,
global_filter: Option<Vec<String>>,
enabled_channels: Vec<CachedChannel>,
last_refresh: Instant,
}
impl EventConfigCache {
fn new() -> Self {
Self {
lang: Lang::default(),
global_filter: None,
enabled_channels: Vec::new(),
// Force refresh on first use
last_refresh: Instant::now() - Duration::from_secs(CONFIG_CACHE_TTL_SECS + 1),
}
}
async fn refresh_if_needed(&mut self, db: &DatabaseConnection) {
if self.last_refresh.elapsed() < Duration::from_secs(CONFIG_CACHE_TTL_SECS) {
return;
}
if let Ok(Some(val)) = app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY).await {
self.lang = Lang::from_str_lossy(&val);
}
// Parse as Option<Vec<String>> so JSON "null" → None (intentional, not accidental)
self.global_filter = app_metadata_service::get_value(db, EVENT_FILTER_KEY)
.await
.ok()
.flatten()
.and_then(|json| {
serde_json::from_str::<Option<Vec<String>>>(&json)
.ok()
.flatten()
});
if let Ok(channels) = chat_channel_service::list_enabled(db).await {
self.enabled_channels = channels
.into_iter()
.map(|ch| CachedChannel {
id: ch.id,
event_filter_json: ch.event_filter_json,
})
.collect();
}
self.last_refresh = Instant::now();
}
}
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();
let mut config = EventConfigCache::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;
}
};
config.refresh_if_needed(&db_conn).await;
// Prune stale debounce entries
last_push.retain(|_, t| t.elapsed() < Duration::from_secs(DEBOUNCE_SECS * 2));
if let Some((event_type, msg)) =
parse_event(&event.channel, event.payload.as_ref(), config.lang)
{
// Global event filter check
if let Some(filter) = &config.global_filter {
if !filter.contains(&event_type) {
continue;
}
}
for ch in &config.enabled_channels {
// Per-channel 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: skip if same event type was pushed to this channel recently
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;
}
}
// Send
let send_result = manager.send_to_channel(ch.id, &msg).await;
let (status, error_detail) = match &send_result {
Ok(_) => {
// Only update debounce timestamp on success
last_push.insert(key, now);
("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;
}
}
}
})
}
fn parse_event(
channel: &str,
payload: &serde_json::Value,
lang: Lang,
) -> Option<(String, RichMessage)> {
match channel {
"acp://event" => parse_acp_event(payload, lang),
_ => None,
}
}
fn parse_acp_event(payload: &serde_json::Value, lang: Lang) -> Option<(String, RichMessage)> {
let event_type = payload.get("type")?.as_str()?;
match event_type {
"turn_complete" => {
let stop_reason = payload
.get("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
.get("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, lang),
))
}
"error" => {
let agent_type = payload
.get("agent_type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown Agent");
let message = payload
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
Some((
"error".to_string(),
message_formatter::format_agent_error(agent_type, message, lang),
))
}
_ => None,
}
}
File diff suppressed because it is too large Load Diff
-339
View File
@@ -1,339 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use sea_orm::DatabaseConnection;
use tokio::sync::{mpsc, Mutex};
use super::error::ChatChannelError;
use super::session_bridge::SessionBridge;
use super::traits::ChatChannelBackend;
use super::types::*;
use crate::acp::manager::ConnectionManager;
use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster};
struct ActiveChannel {
id: i32,
name: String,
channel_type: ChannelType,
backend: Arc<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>>>,
broadcaster: Mutex<Option<Arc<WebEventBroadcaster>>>,
}
pub struct ChatChannelManager {
inner: Arc<Inner>,
}
impl Default for ChatChannelManager {
fn default() -> Self {
Self::new()
}
}
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)),
broadcaster: Mutex::new(None),
}),
}
}
/// 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()
}
/// Emit a status change event to the frontend via broadcaster.
async fn emit_status_event(&self, channel_id: i32, status: &str) {
if let Some(broadcaster) = self.inner.broadcaster.lock().await.as_ref() {
broadcaster.send(
"chat-channel://status",
&serde_json::json!({
"channel_id": channel_id,
"status": status,
}),
);
}
}
pub async fn add_channel(
&self,
id: i32,
name: String,
channel_type: ChannelType,
backend: Box<dyn ChatChannelBackend>,
) -> Result<(), ChatChannelError> {
let backend: Arc<dyn ChatChannelBackend> = Arc::from(backend);
// Stop existing channel if present (prevents task leak on duplicate connect)
let old = self.inner.channels.lock().await.remove(&id);
if let Some(existing) = old {
let _ = existing.backend.stop().await;
}
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);
self.emit_status_event(id, "connected").await;
Ok(())
}
pub async fn remove_channel(&self, id: i32) -> Result<(), ChatChannelError> {
let removed = self.inner.channels.lock().await.remove(&id);
if let Some(channel) = removed {
channel.backend.stop().await?;
self.emit_status_event(id, "disconnected").await;
}
Ok(())
}
pub async fn stop_all(&self) {
let drained: Vec<ActiveChannel> = {
let mut channels = self.inner.channels.lock().await;
channels.drain().map(|(_, ch)| ch).collect()
};
for channel in drained {
let _ = channel.backend.stop().await;
}
}
pub async fn send_to_channel(
&self,
channel_id: i32,
message: &RichMessage,
) -> Result<SentMessageId, ChatChannelError> {
let backend = {
let channels = self.inner.channels.lock().await;
channels
.get(&channel_id)
.ok_or(ChatChannelError::NotFound(channel_id))?
.backend
.clone()
};
backend.send_rich_message(message).await
}
pub async fn send_to_all(&self, message: &RichMessage) {
let backends: Vec<Arc<dyn ChatChannelBackend>> = {
let channels = self.inner.channels.lock().await;
channels.values().map(|ch| ch.backend.clone()).collect()
};
for backend in backends {
let _ = backend.send_rich_message(message).await;
}
}
pub async fn get_status(&self) -> Vec<crate::models::ChannelStatusInfo> {
let entries: Vec<(i32, String, String, Arc<dyn ChatChannelBackend>)> = {
let channels = self.inner.channels.lock().await;
channels
.values()
.map(|ch| {
(
ch.id,
ch.name.clone(),
ch.channel_type.to_string(),
ch.backend.clone(),
)
})
.collect()
};
let mut result = Vec::with_capacity(entries.len());
for (id, name, ct, backend) in entries {
let status = backend.status().await;
result.push(crate::models::ChannelStatusInfo {
channel_id: id,
name,
channel_type: ct,
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 backend = {
let channels = self.inner.channels.lock().await;
channels
.get(&id)
.ok_or(ChatChannelError::NotFound(id))?
.backend
.clone()
};
backend.test_connection().await
}
pub async fn is_connected(&self, id: i32) -> bool {
let backend = {
let channels = self.inner.channels.lock().await;
channels.get(&id).map(|ch| ch.backend.clone())
};
if let Some(b) = backend {
b.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,
conn_mgr: ConnectionManager,
emitter: EventEmitter,
) {
// Store broadcaster for status event emission
*self.inner.broadcaster.lock().await = Some(broadcaster.clone());
let db_conn2 = db_conn.clone();
// Create shared session bridge
let bridge = Arc::new(Mutex::new(SessionBridge::new()));
// Spawn event subscriber
let manager_for_events = self.clone_ref();
super::event_subscriber::spawn_event_subscriber(
broadcaster.clone(),
manager_for_events,
db_conn.clone(),
);
// Spawn session event subscriber (ACP event routing to channels)
let manager_for_session_events = self.clone_ref();
super::session_event_subscriber::spawn_session_event_subscriber(
broadcaster,
bridge.clone(),
manager_for_session_events,
conn_mgr.clone_ref(),
db_conn.clone(),
);
// Spawn command dispatcher
if let Some(command_rx) = self.take_command_receiver().await {
eprintln!("[ChatChannel] command dispatcher started");
let manager_for_cmds = self.clone_ref();
super::command_dispatcher::spawn_command_dispatcher(
command_rx,
manager_for_cmds,
db_conn.clone(),
conn_mgr,
emitter,
bridge,
);
} else {
eprintln!("[ChatChannel] WARNING: command_rx already taken, dispatcher NOT started");
}
// 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(_) => {
eprintln!(
"[ChatChannel] unknown channel type '{}' for '{}' (id={}), skipping",
ch.channel_type, ch.name, ch.id
);
continue;
}
};
let config: serde_json::Value = match serde_json::from_str(&ch.config_json) {
Ok(v) => v,
Err(e) => {
eprintln!(
"[ChatChannel] invalid config for '{}' (id={}): {e}, skipping",
ch.name, ch.id
);
continue;
}
};
let token = match crate::keyring_store::get_channel_token(ch.id) {
Some(t) => t,
None => {
eprintln!(
"[ChatChannel] no token found for '{}' (id={}), skipping auto-connect",
ch.name, ch.id
);
continue;
}
};
let backend = match super::backends::create_backend(ch.id, channel_type, &config, token)
{
Ok(b) => b,
Err(e) => {
eprintln!(
"[ChatChannel] failed to create backend for '{}' (id={}): {e}",
ch.name, ch.id
);
continue;
}
};
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);
}
}
}
}
@@ -1,68 +0,0 @@
use super::i18n::{self, Lang};
use super::types::{MessageLevel, RichMessage};
pub fn format_turn_complete(agent_type: &str, stop_reason: &str, lang: Lang) -> RichMessage {
let reason = match stop_reason {
"end_turn" => i18n::stop_reason_end_turn(lang),
"cancelled" => i18n::stop_reason_cancelled(lang),
_ => stop_reason,
};
RichMessage::info(i18n::turn_complete_body(lang, agent_type))
.with_title(i18n::turn_complete_title(lang))
.with_field(i18n::stop_reason_label(lang), reason)
}
pub fn format_agent_error(agent_type: &str, message: &str, lang: Lang) -> RichMessage {
RichMessage {
title: Some(i18n::agent_error_title(lang).to_string()),
body: i18n::agent_error_body(lang, agent_type),
fields: vec![(
i18n::error_message_label(lang).to_string(),
message.to_string(),
)],
level: MessageLevel::Error,
}
}
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, lang: Lang) -> RichMessage {
let mut body = i18n::daily_report_summary(lang, &report.date);
body.push_str(&format!(
"\n\n{}",
i18n::total_sessions(lang, report.total_conversations)
));
if !report.conversations_by_agent.is_empty() {
body.push_str(&format!("\n\n{}", i18n::by_agent_label(lang)));
for (agent, count) in &report.conversations_by_agent {
body.push_str(&format!(
"\n {}",
i18n::agent_session_count(lang, agent, *count)
));
}
}
if !report.projects_involved.is_empty() {
body.push_str(&format!(
"\n\n{}",
i18n::projects_label(lang, &report.projects_involved.join(", "))
));
}
if !report.key_activities.is_empty() {
body.push_str(&format!("\n\n{}", i18n::key_activities_label(lang)));
for activity in &report.key_activities {
body.push_str(&format!("\n • {}", activity));
}
}
RichMessage::info(body).with_title(i18n::daily_report_title(lang))
}
-14
View File
@@ -1,14 +0,0 @@
pub mod backends;
pub mod command_dispatcher;
pub mod command_handlers;
pub mod error;
pub mod event_subscriber;
pub mod i18n;
pub mod manager;
pub mod message_formatter;
pub mod scheduler;
pub mod session_bridge;
pub mod session_commands;
pub mod session_event_subscriber;
pub mod traits;
pub mod types;
-160
View File
@@ -1,160 +0,0 @@
use std::collections::HashSet;
use chrono::{Local, NaiveDate, Timelike, Utc};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};
use tokio::task::JoinHandle;
use super::i18n::Lang;
use super::manager::ChatChannelManager;
use super::message_formatter::{self, DailyReportData};
use crate::db::entities::conversation;
use crate::db::service::{
app_metadata_service, chat_channel_message_log_service, chat_channel_service,
};
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
/// Days to retain message logs before cleanup.
const LOG_RETENTION_DAYS: i64 = 30;
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();
let mut last_cleanup_date: Option<NaiveDate> = None;
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);
// Periodic log cleanup: once per day
if last_cleanup_date != Some(today) {
last_cleanup_date = Some(today);
let cutoff = Utc::now() - chrono::Duration::days(LOG_RETENTION_DAYS);
match chat_channel_message_log_service::cleanup_old_logs(&db_conn, cutoff).await {
Ok(n) if n > 0 => {
eprintln!("[ChatChannel] cleaned up {n} old message logs");
}
Err(e) => {
eprintln!("[ChatChannel] log cleanup failed: {e}");
}
_ => {}
}
}
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;
}
let lang = load_lang(&db_conn).await;
// Generate and send report
let report = generate_daily_report(&db_conn).await;
let message = message_formatter::format_daily_report(&report, lang);
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 load_lang(db: &DatabaseConnection) -> Lang {
app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY)
.await
.ok()
.flatten()
.map(|v| Lang::from_str_lossy(&v))
.unwrap_or_default()
}
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,
}
}
@@ -1,79 +0,0 @@
use std::collections::HashMap;
use std::time::Instant;
use crate::acp::types::PermissionOptionInfo;
use crate::chat_channel::types::SentMessageId;
use crate::models::agent::AgentType;
pub struct PendingPermission {
pub request_id: String,
pub tool_description: String,
pub options: Vec<PermissionOptionInfo>,
pub sent_message_id: Option<SentMessageId>,
}
pub struct ActiveSession {
pub channel_id: i32,
pub sender_id: String,
pub conversation_id: i32,
pub connection_id: String,
pub agent_type: AgentType,
pub content_buffer: String,
pub tool_calls: Vec<String>,
/// Stores raw_input by tool_call_id for detail extraction on completion.
pub tool_call_inputs: HashMap<String, String>,
pub last_flushed: Instant,
pub pending_prompt: Option<String>,
pub permission_pending: Option<PendingPermission>,
}
#[derive(Default)]
pub struct SessionBridge {
sessions: HashMap<String, ActiveSession>,
}
impl SessionBridge {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, connection_id: String, session: ActiveSession) {
self.sessions.insert(connection_id, session);
}
pub fn remove(&mut self, connection_id: &str) -> Option<ActiveSession> {
self.sessions.remove(connection_id)
}
pub fn get(&self, connection_id: &str) -> Option<&ActiveSession> {
self.sessions.get(connection_id)
}
pub fn get_mut(&mut self, connection_id: &str) -> Option<&mut ActiveSession> {
self.sessions.get_mut(connection_id)
}
pub fn find_by_sender(&self, channel_id: i32, sender_id: &str) -> Option<&ActiveSession> {
self.sessions
.values()
.find(|s| s.channel_id == channel_id && s.sender_id == sender_id)
}
pub fn find_by_sender_mut(
&mut self,
channel_id: i32,
sender_id: &str,
) -> Option<&mut ActiveSession> {
self.sessions
.values_mut()
.find(|s| s.channel_id == channel_id && s.sender_id == sender_id)
}
pub fn all_sessions(&self) -> impl Iterator<Item = &ActiveSession> {
self.sessions.values()
}
pub fn all_sessions_mut(&mut self) -> impl Iterator<Item = &mut ActiveSession> {
self.sessions.values_mut()
}
}
@@ -1,799 +0,0 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Instant;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use tokio::sync::Mutex;
use super::i18n::{self, Lang};
use super::session_bridge::{ActiveSession, SessionBridge};
use super::types::{MessageLevel, RichMessage};
use crate::acp::manager::ConnectionManager;
use crate::acp::registry::all_acp_agents;
use crate::acp::types::PromptInputBlock;
use crate::db::entities::conversation;
use crate::db::service::{conversation_service, folder_service, sender_context_service};
use crate::models::agent::AgentType;
use crate::web::event_bridge::EventEmitter;
pub struct FollowupRequest<'a> {
pub db: &'a DatabaseConnection,
pub text: &'a str,
pub channel_id: i32,
pub sender_id: &'a str,
pub conn_mgr: &'a ConnectionManager,
pub bridge: &'a Arc<Mutex<SessionBridge>>,
pub lang: Lang,
pub prefix: &'a str,
}
// ── /folder ──
pub async fn handle_folder(
db: &DatabaseConnection,
args: &str,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
if args.is_empty() {
return list_folders(db, channel_id, sender_id, lang, prefix).await;
}
// Try parse as index (1-based)
if let Ok(idx) = args.parse::<usize>() {
return select_folder_by_index(db, idx, channel_id, sender_id, lang, prefix).await;
}
// Treat as path
select_folder_by_path(db, args, channel_id, sender_id, lang).await
}
async fn list_folders(
db: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
let folders = match folder_service::list_folders(db).await {
Ok(f) => f,
Err(e) => {
return RichMessage::error(format!("{}{e}", i18n::failed_to_list_folders_label(lang)));
}
};
if folders.is_empty() {
return RichMessage::info(i18n::no_folders_found(lang))
.with_title(i18n::folder_title(lang));
}
let ctx = sender_context_service::get_or_create(db, channel_id, sender_id)
.await
.ok();
let mut body = String::new();
for (i, f) in folders.iter().take(10).enumerate() {
let current = ctx
.as_ref()
.and_then(|c| c.current_folder_id)
.map(|id| id == f.id)
.unwrap_or(false);
let marker = if current { " [*]" } else { "" };
body.push_str(&format!("{}. {}{} ({})\n", i + 1, f.name, marker, f.path));
}
body.push_str(&format!("\n{}", i18n::folder_select_hint(lang, prefix)));
RichMessage::info(body.trim_end()).with_title(i18n::folder_title(lang))
}
async fn select_folder_by_index(
db: &DatabaseConnection,
idx: usize,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
if idx == 0 {
return RichMessage::info(i18n::index_starts_from_one(lang));
}
let folders = match folder_service::list_folders(db).await {
Ok(f) => f,
Err(e) => {
return RichMessage::error(format!("{}{e}", i18n::failed_to_list_folders_label(lang)));
}
};
let Some(folder) = folders.get(idx - 1) else {
return RichMessage::info(i18n::folder_index_out_of_range(lang, prefix));
};
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(folder.id)).await;
RichMessage::info(format!("{} ({})", folder.name, folder.path))
.with_title(i18n::folder_selected_title(lang))
}
async fn select_folder_by_path(
db: &DatabaseConnection,
path: &str,
channel_id: i32,
sender_id: &str,
lang: Lang,
) -> RichMessage {
let entry = match folder_service::add_folder(db, path).await {
Ok(e) => e,
Err(e) => {
return RichMessage::error(format!("{}{e}", i18n::failed_to_add_folder_label(lang)));
}
};
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(entry.id)).await;
RichMessage::info(format!("{} ({})", entry.name, entry.path))
.with_title(i18n::folder_selected_title(lang))
}
// ── /agent ──
pub async fn handle_agent(
db: &DatabaseConnection,
args: &str,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
if args.is_empty() {
return list_agents(db, channel_id, sender_id, lang, prefix).await;
}
// Try parse as index
if let Ok(idx) = args.parse::<usize>() {
return select_agent_by_index(db, idx, channel_id, sender_id, lang, prefix).await;
}
// Try parse as agent type name
select_agent_by_name(db, args, channel_id, sender_id, lang).await
}
async fn list_agents(
db: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
let agents = all_acp_agents();
let ctx = sender_context_service::get_or_create(db, channel_id, sender_id)
.await
.ok();
let mut body = String::new();
for (i, at) in agents.iter().enumerate() {
let at_str = agent_type_to_string(*at);
let current = ctx
.as_ref()
.and_then(|c| c.current_agent_type.as_deref())
.map(|s| s == at_str)
.unwrap_or(false);
let marker = if current { " [*]" } else { "" };
body.push_str(&format!("{}. {}{}\n", i + 1, at, marker));
}
body.push_str(&format!("\n{}", i18n::agent_select_hint(lang, prefix)));
RichMessage::info(body.trim_end()).with_title(i18n::agent_title(lang))
}
async fn select_agent_by_index(
db: &DatabaseConnection,
idx: usize,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
let agents = all_acp_agents();
if idx == 0 || idx > agents.len() {
return RichMessage::info(i18n::agent_index_out_of_range(lang, prefix));
}
let at = agents[idx - 1];
let at_str = agent_type_to_string(at);
let _ = sender_context_service::update_agent(db, channel_id, sender_id, Some(at_str)).await;
RichMessage::info(at.to_string()).with_title(i18n::agent_selected_title(lang))
}
async fn select_agent_by_name(
db: &DatabaseConnection,
name: &str,
channel_id: i32,
sender_id: &str,
lang: Lang,
) -> RichMessage {
let at = match parse_agent_type(name) {
Some(a) => a,
None => {
return RichMessage::info(format!("{}{}", i18n::unknown_agent_label(lang), name));
}
};
let at_str = agent_type_to_string(at);
let _ = sender_context_service::update_agent(db, channel_id, sender_id, Some(at_str)).await;
RichMessage::info(at.to_string()).with_title(i18n::agent_selected_title(lang))
}
// ── /task ──
#[allow(clippy::too_many_arguments)]
pub async fn handle_task(
db: &DatabaseConnection,
task_description: &str,
channel_id: i32,
sender_id: &str,
conn_mgr: &ConnectionManager,
emitter: &EventEmitter,
bridge: &Arc<Mutex<SessionBridge>>,
lang: Lang,
prefix: &str,
) -> RichMessage {
if task_description.is_empty() {
return RichMessage::info(i18n::task_usage(lang, prefix));
}
// 1. Load sender context
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang)));
}
};
let folder_id = match ctx.current_folder_id {
Some(id) => id,
None => {
return RichMessage::info(i18n::no_folder_selected(lang, prefix));
}
};
// 2. Get folder info
let folder = match folder_service::get_folder_by_id(db, folder_id).await {
Ok(Some(f)) => f,
_ => {
return RichMessage::info(i18n::folder_not_found_with_hint(lang, prefix));
}
};
// 3. Resolve agent type
let agent_type = match resolve_agent_type(&ctx.current_agent_type, &folder.default_agent_type) {
Some(at) => at,
None => {
return RichMessage::info(i18n::no_agent_selected(lang, prefix));
}
};
// 4. Create conversation record
let conv = match conversation_service::create(
db,
folder_id,
agent_type,
Some(truncate_title(task_description)),
folder.git_branch.clone(),
)
.await
{
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_create_conversation_label(lang)
));
}
};
// 5. Spawn ACP agent
let owner_label = format!("chat_channel:{}:{}", channel_id, sender_id);
let connection_id = match conn_mgr
.spawn_agent(
agent_type,
Some(folder.path.clone()),
None,
BTreeMap::new(),
owner_label,
emitter.clone(),
)
.await
{
Ok(id) => id,
Err(e) => {
// Clean up the conversation record
let _ = conversation_service::update_status(
db,
conv.id,
conversation::ConversationStatus::Cancelled,
)
.await;
return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang)));
}
};
// 6. Register in bridge (prompt will be sent after SessionStarted event)
{
let session = ActiveSession {
channel_id,
sender_id: sender_id.to_string(),
conversation_id: conv.id,
connection_id: connection_id.clone(),
agent_type,
content_buffer: String::new(),
tool_calls: Vec::new(),
tool_call_inputs: std::collections::HashMap::new(),
last_flushed: Instant::now(),
pending_prompt: Some(task_description.to_string()),
permission_pending: None,
};
bridge.lock().await.register(connection_id.clone(), session);
}
// 7. Update sender context
let _ = sender_context_service::update_session(
db,
channel_id,
sender_id,
Some(conv.id),
Some(connection_id),
)
.await;
RichMessage::info(format!("[{}] #{} @ {}", agent_type, conv.id, folder.name,))
.with_title(i18n::task_started_title(lang))
}
// ── /sessions ──
pub async fn handle_sessions(
db: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
lang: Lang,
prefix: &str,
) -> RichMessage {
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang)));
}
};
let folder_id = match ctx.current_folder_id {
Some(id) => id,
None => {
return RichMessage::info(i18n::no_folder_selected(lang, prefix));
}
};
let folder = match folder_service::get_folder_by_id(db, folder_id).await {
Ok(Some(f)) => f,
_ => {
return RichMessage::info(i18n::folder_not_found(lang));
}
};
let convs = match conversation_service::list_by_folder(
db,
folder_id,
None,
None,
None,
Some("in_progress".to_string()),
)
.await
{
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!("{}{e}", i18n::failed_to_list_sessions_label(lang)));
}
};
if convs.is_empty() {
return RichMessage::info(i18n::no_active_sessions_in_folder(lang)).with_title(format!(
"{} - {}",
i18n::sessions_title(lang),
folder.name
));
}
let mut body = String::new();
for (i, c) in convs.iter().take(10).enumerate() {
let title = c.title.as_deref().unwrap_or("(untitled)");
let current = ctx
.current_conversation_id
.map(|id| id == c.id)
.unwrap_or(false);
let marker = if current { " [*]" } else { "" };
body.push_str(&format!(
"{}. [{}] {} (#{}){} \n",
i + 1,
c.agent_type,
title,
c.id,
marker,
));
}
body.push_str(&format!("\n{}", i18n::sessions_resume_hint(lang, prefix)));
RichMessage::info(body.trim_end()).with_title(format!(
"{} - {}",
i18n::sessions_title(lang),
folder.name
))
}
// ── /resume ──
#[allow(clippy::too_many_arguments)]
pub async fn handle_resume(
db: &DatabaseConnection,
args: &str,
channel_id: i32,
sender_id: &str,
conn_mgr: &ConnectionManager,
emitter: &EventEmitter,
bridge: &Arc<Mutex<SessionBridge>>,
lang: Lang,
prefix: &str,
) -> RichMessage {
if args.is_empty() {
return list_recent_sessions(db, lang, prefix).await;
}
let conversation_id: i32 = match args.parse() {
Ok(id) => id,
Err(_) => {
return list_recent_sessions(db, lang, prefix).await;
}
};
let conv = match conversation_service::get_by_id(db, conversation_id).await {
Ok(c) => c,
Err(_) => {
return RichMessage::info(i18n::conversation_not_found(lang));
}
};
let folder = match folder_service::get_folder_by_id(db, conv.folder_id).await {
Ok(Some(f)) => f,
_ => {
return RichMessage::info(i18n::folder_not_found(lang));
}
};
// Spawn agent with session_id for resume
let owner_label = format!("chat_channel:{}:{}", channel_id, sender_id);
let connection_id = match conn_mgr
.spawn_agent(
conv.agent_type,
Some(folder.path.clone()),
conv.external_id.clone(),
BTreeMap::new(),
owner_label,
emitter.clone(),
)
.await
{
Ok(id) => id,
Err(e) => {
return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang)));
}
};
// Register in bridge (no pending prompt for resume)
{
let session = ActiveSession {
channel_id,
sender_id: sender_id.to_string(),
conversation_id: conv.id,
connection_id: connection_id.clone(),
agent_type: conv.agent_type,
content_buffer: String::new(),
tool_calls: Vec::new(),
tool_call_inputs: std::collections::HashMap::new(),
last_flushed: Instant::now(),
pending_prompt: None,
permission_pending: None,
};
bridge.lock().await.register(connection_id.clone(), session);
}
// Update sender context
let _ = sender_context_service::update_session(
db,
channel_id,
sender_id,
Some(conv.id),
Some(connection_id),
)
.await;
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(conv.folder_id))
.await;
let title = conv.title.as_deref().unwrap_or("(untitled)");
RichMessage::info(format!(
"[{}] #{} {} @ {}",
conv.agent_type, conv.id, title, folder.name,
))
.with_title(i18n::session_resumed_title(lang))
}
// ── /cancel ──
pub async fn handle_cancel(
db: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
conn_mgr: &ConnectionManager,
bridge: &Arc<Mutex<SessionBridge>>,
lang: Lang,
) -> RichMessage {
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang)));
}
};
let connection_id = match &ctx.current_connection_id {
Some(id) => id.clone(),
None => {
return RichMessage::info(i18n::no_active_session_to_cancel(lang));
}
};
// Cancel the ACP connection
let _ = conn_mgr.cancel(&connection_id).await;
// Remove from bridge
bridge.lock().await.remove(&connection_id);
// Update conversation status
if let Some(conv_id) = ctx.current_conversation_id {
let _ = conversation_service::update_status(
db,
conv_id,
conversation::ConversationStatus::Cancelled,
)
.await;
}
// Clear session from context
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
RichMessage::info(i18n::task_cancelled_body(lang)).with_title(i18n::task_cancelled_title(lang))
}
// ── /approve, /deny ──
#[allow(clippy::too_many_arguments)]
pub async fn handle_permission_response(
approve: bool,
always: bool,
db: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
conn_mgr: &ConnectionManager,
bridge: &Arc<Mutex<SessionBridge>>,
lang: Lang,
) -> RichMessage {
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang)));
}
};
let connection_id = match &ctx.current_connection_id {
Some(id) => id.clone(),
None => {
return RichMessage::info(i18n::no_active_session(lang));
}
};
let pending = {
let mut bridge_guard = bridge.lock().await;
let session = match bridge_guard.get_mut(&connection_id) {
Some(s) => s,
None => {
return RichMessage::info(i18n::no_active_session_found(lang));
}
};
session.permission_pending.take()
};
let pending = match pending {
Some(p) => p,
None => {
return RichMessage::info(i18n::no_pending_permission(lang));
}
};
// Find the appropriate option_id
let option_id = if approve {
pending
.options
.iter()
.find(|o| o.kind == "allow" || o.kind == "allowForSession")
.or_else(|| pending.options.first())
.map(|o| o.option_id.clone())
} else {
pending
.options
.iter()
.find(|o| o.kind == "deny")
.or_else(|| pending.options.last())
.map(|o| o.option_id.clone())
};
let Some(option_id) = option_id else {
return RichMessage::info(i18n::no_valid_permission_option(lang));
};
if let Err(e) = conn_mgr
.respond_permission(&connection_id, &pending.request_id, &option_id)
.await
{
return RichMessage::error(format!(
"{}{e}",
i18n::failed_permission_response_label(lang)
));
}
// Update auto_approve if requested
if always && approve {
let _ = sender_context_service::update_auto_approve(db, channel_id, sender_id, true).await;
}
let action = if approve {
i18n::approved_label(lang)
} else {
i18n::denied_label(lang)
};
let mut msg = RichMessage::info(format!("{}: {}", action, pending.tool_description));
if always && approve {
msg = msg.with_field("", i18n::auto_approve_enabled(lang));
}
msg.with_title(i18n::permission_response_title(lang))
}
// ── follow-up (non-command text) ──
pub async fn handle_followup(req: FollowupRequest<'_>) -> RichMessage {
let ctx =
match sender_context_service::get_or_create(req.db, req.channel_id, req.sender_id).await {
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_load_context_label(req.lang)
));
}
};
let connection_id = match &ctx.current_connection_id {
Some(id) => id.clone(),
None => {
return RichMessage::info(i18n::no_active_session_use_task(req.lang, req.prefix));
}
};
// Check connection exists in bridge
{
let bridge_guard = req.bridge.lock().await;
if bridge_guard.get(&connection_id).is_none() {
// Connection lost, clear context
drop(bridge_guard);
let _ =
sender_context_service::clear_session(req.db, req.channel_id, req.sender_id).await;
return RichMessage::info(i18n::session_connection_lost(req.lang, req.prefix));
}
}
// Send prompt to agent
let blocks = vec![PromptInputBlock::Text {
text: req.text.to_string(),
}];
if let Err(e) = req.conn_mgr.send_prompt(&connection_id, blocks).await {
// Connection may have died
req.bridge.lock().await.remove(&connection_id);
let _ = sender_context_service::clear_session(req.db, req.channel_id, req.sender_id).await;
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_send_message_label(req.lang)
));
}
RichMessage::info(i18n::message_sent(req.lang))
}
// ── /resume (list recent) ──
async fn list_recent_sessions(db: &DatabaseConnection, lang: Lang, prefix: &str) -> RichMessage {
let recent = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.order_by_desc(conversation::Column::CreatedAt)
.limit(10)
.all(db)
.await
{
Ok(rows) => rows,
Err(e) => {
return RichMessage {
title: Some(i18n::query_failed_title(lang).to_string()),
body: e.to_string(),
fields: Vec::new(),
level: MessageLevel::Error,
};
}
};
if recent.is_empty() {
return RichMessage::info(i18n::no_conversations_found(lang))
.with_title(i18n::recent_conversations_title(lang));
}
let mut body = String::new();
for conv in &recent {
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
let agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,));
}
body.push_str(&format!("\n{}", i18n::recent_resume_hint(lang, prefix)));
RichMessage::info(body.trim_end()).with_title(i18n::recent_conversations_title(lang))
}
// ── Helpers ──
fn agent_type_to_string(at: AgentType) -> String {
serde_json::to_value(at)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default()
}
fn parse_agent_type(name: &str) -> Option<AgentType> {
let normalized = name.to_lowercase().replace([' ', '-'], "_");
serde_json::from_value(serde_json::Value::String(normalized)).ok()
}
fn resolve_agent_type(
sender_agent: &Option<String>,
folder_default: &Option<AgentType>,
) -> Option<AgentType> {
if let Some(ref at_str) = sender_agent {
if let Some(at) = parse_agent_type(at_str) {
return Some(at);
}
}
folder_default.as_ref().copied()
}
fn truncate_title(s: &str) -> String {
if s.chars().count() <= 80 {
s.to_string()
} else {
let truncated: String = s.chars().take(77).collect();
format!("{truncated}...")
}
}
@@ -1,727 +0,0 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use sea_orm::DatabaseConnection;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use super::i18n::Lang;
use super::session_bridge::{PendingPermission, SessionBridge};
use super::types::{MessageLevel, RichMessage};
use crate::acp::manager::ConnectionManager;
use crate::acp::types::PromptInputBlock;
use crate::db::service::{app_metadata_service, conversation_service, sender_context_service};
use crate::web::event_bridge::WebEventBroadcaster;
use super::manager::ChatChannelManager;
const FLUSH_INTERVAL_SECS: u64 = 10;
const BUFFER_FLUSH_THRESHOLD: usize = 500;
const MAX_MESSAGE_LEN: usize = 2000;
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
const COMMAND_PREFIX_KEY: &str = "chat_command_prefix";
const DEFAULT_COMMAND_PREFIX: &str = "/";
pub fn spawn_session_event_subscriber(
broadcaster: Arc<WebEventBroadcaster>,
bridge: Arc<Mutex<SessionBridge>>,
manager: ChatChannelManager,
conn_mgr: ConnectionManager,
db_conn: DatabaseConnection,
) -> JoinHandle<()> {
let mut rx = broadcaster.subscribe();
tokio::spawn(async move {
let mut last_heartbeat = Instant::now();
loop {
tokio::select! {
result = rx.recv() => {
let event = match result {
Ok(e) => e,
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
eprintln!("[SessionEventSub] lagged {n} events");
continue;
}
Err(_) => break,
};
if event.channel == "acp://event" {
handle_acp_event_payload(
event.payload.as_ref(),
&bridge,
&manager,
&conn_mgr,
&db_conn,
)
.await;
}
}
_ = tokio::time::sleep(Duration::from_secs(FLUSH_INTERVAL_SECS)) => {
if last_heartbeat.elapsed() >= Duration::from_secs(FLUSH_INTERVAL_SECS) {
flush_progress(&bridge, &manager, &db_conn).await;
last_heartbeat = Instant::now();
}
}
}
}
})
}
async fn get_lang(db: &DatabaseConnection) -> Lang {
app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY)
.await
.ok()
.flatten()
.map(|v| Lang::from_str_lossy(&v))
.unwrap_or_default()
}
async fn get_prefix(db: &DatabaseConnection) -> String {
app_metadata_service::get_value(db, COMMAND_PREFIX_KEY)
.await
.ok()
.flatten()
.unwrap_or_else(|| DEFAULT_COMMAND_PREFIX.to_string())
}
async fn handle_acp_event_payload(
payload: &serde_json::Value,
bridge: &Arc<Mutex<SessionBridge>>,
manager: &ChatChannelManager,
conn_mgr: &ConnectionManager,
db: &DatabaseConnection,
) {
let event_type = match payload.get("type").and_then(|v| v.as_str()) {
Some(t) => t,
None => return,
};
let connection_id = match payload.get("connection_id").and_then(|v| v.as_str()) {
Some(id) => id,
None => return,
};
match event_type {
"session_started" => {
let session_id = payload
.get("session_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) {
let _ = conversation_service::update_external_id(
db,
session.conversation_id,
session_id.to_string(),
)
.await;
if let Some(prompt_text) = session.pending_prompt.take() {
let blocks = vec![PromptInputBlock::Text { text: prompt_text }];
if let Err(e) = conn_mgr.send_prompt(connection_id, blocks).await {
eprintln!("[SessionEventSub] failed to send pending prompt: {e}");
let channel_id = session.channel_id;
let msg = RichMessage::error(format!("Failed to send task: {e}"));
let _ = manager.send_to_channel(channel_id, &msg).await;
}
}
}
}
"content_delta" => {
let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
// Collect flush info under the lock, then release before any IO.
let flush_info: Option<(i32, String, Option<String>)> = {
let mut guard = bridge.lock().await;
match guard.get_mut(connection_id) {
Some(session) => {
session.content_buffer.push_str(text);
if session.content_buffer.len() >= BUFFER_FLUSH_THRESHOLD
&& session.last_flushed.elapsed() >= Duration::from_secs(2)
{
session.last_flushed = Instant::now();
Some((
session.channel_id,
session.agent_type.to_string(),
session.tool_calls.last().cloned(),
))
} else {
None
}
}
None => None,
}
};
if let Some((channel_id, agent_label, last_tool)) = flush_info {
let lang = get_lang(db).await;
let mut status = super::i18n::agent_responding(lang, &agent_label);
if let Some(tool) = last_tool {
status.push_str(&format!(" | {tool}"));
}
let msg = RichMessage::info(status);
let _ = manager.send_to_channel(channel_id, &msg).await;
}
}
"tool_call" => {
let title = payload
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("tool");
let tool_call_id = payload
.get("tool_call_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let raw_input = payload.get("raw_input").and_then(|v| v.as_str());
let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) {
// Store title for progress indicator; store raw_input for later
session.tool_calls.push(title.to_string());
if let Some(input) = raw_input {
session
.tool_call_inputs
.insert(tool_call_id.to_string(), input.to_string());
}
}
}
"tool_call_update" => {
let title = payload.get("title").and_then(|v| v.as_str());
let status = payload.get("status").and_then(|v| v.as_str());
let tool_call_id = payload
.get("tool_call_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let raw_input = payload.get("raw_input").and_then(|v| v.as_str());
let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) {
// Accumulate raw_input if newly available
if let Some(input) = raw_input {
session
.tool_call_inputs
.insert(tool_call_id.to_string(), input.to_string());
}
if status == Some("completed") {
let stored_input = session.tool_call_inputs.remove(tool_call_id);
let effective_title = title.unwrap_or("tool");
let input_ref = stored_input.as_deref().or(raw_input);
let detail = format_tool_call_detail(effective_title, input_ref);
let channel_id = session.channel_id;
drop(guard);
let msg = RichMessage::info(format!(">> {detail}"));
let _ = manager.send_to_channel(channel_id, &msg).await;
}
}
}
"permission_request" => {
let request_id = payload
.get("request_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let tool_call = payload
.get("tool_call")
.cloned()
.unwrap_or(serde_json::Value::Null);
let options: Vec<crate::acp::types::PermissionOptionInfo> = payload
.get("options")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) {
let channel_id = session.channel_id;
let sender_id = session.sender_id.clone();
let auto_approve =
sender_context_service::get_or_create(db, channel_id, &sender_id)
.await
.map(|ctx| ctx.auto_approve)
.unwrap_or(false);
if auto_approve {
let option_id = options
.iter()
.find(|o| o.kind == "allow" || o.kind == "allowForSession")
.or_else(|| options.first())
.map(|o| o.option_id.clone());
drop(guard);
if let Some(oid) = option_id {
let _ = conn_mgr
.respond_permission(connection_id, request_id, &oid)
.await;
}
return;
}
let tool_title = tool_call
.get("title")
.and_then(|v| v.as_str())
.or_else(|| tool_call.get("tool_name").and_then(|v| v.as_str()))
.unwrap_or("Unknown tool");
// Extract detail from rawInput / raw_input in the tool_call object
let raw_input_str = tool_call
.get("rawInput")
.or_else(|| tool_call.get("raw_input"))
.and_then(|v| match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Null => None,
other => Some(other.to_string()),
});
let tool_desc = format_tool_call_detail(tool_title, raw_input_str.as_deref());
session.permission_pending = Some(PendingPermission {
request_id: request_id.to_string(),
tool_description: tool_desc.clone(),
options,
sent_message_id: None,
});
drop(guard);
let lang = get_lang(db).await;
let prefix = get_prefix(db).await;
let body = match lang {
Lang::ZhCn | Lang::ZhTw => {
format!("Agent 请求权限: {tool_desc}\n\n{prefix}approve 批准 | {prefix}deny 拒绝 | {prefix}approve always 自动批准")
}
_ => {
format!("Agent requests permission: {tool_desc}\n\n{prefix}approve | {prefix}deny | {prefix}approve always")
}
};
let msg = RichMessage {
title: Some(match lang {
Lang::ZhCn | Lang::ZhTw => "权限请求".to_string(),
_ => "Permission Request".to_string(),
}),
body,
fields: Vec::new(),
level: MessageLevel::Warning,
};
let _ = manager.send_to_channel(channel_id, &msg).await;
}
}
"turn_complete" => {
let stop_reason = payload
.get("stop_reason")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let agent_type = payload
.get("agent_type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
let mut guard = bridge.lock().await;
if let Some(session) = guard.get_mut(connection_id) {
let channel_id = session.channel_id;
let conv_id = session.conversation_id;
let content = std::mem::take(&mut session.content_buffer);
let tool_count = session.tool_calls.len();
session.tool_calls.clear();
session.last_flushed = Instant::now();
drop(guard);
let lang = get_lang(db).await;
let body = format_completion(&content, tool_count, lang);
let msg = RichMessage::info(body)
.with_title(match lang {
Lang::ZhCn | Lang::ZhTw => "任务完成",
_ => "Turn Complete",
})
.with_field("Agent", agent_type)
.with_field(
match lang {
Lang::ZhCn | Lang::ZhTw => "结束原因",
_ => "Stop Reason",
},
localize_stop_reason(stop_reason, lang),
);
let _ = manager.send_to_channel(channel_id, &msg).await;
if stop_reason == "end_turn" {
let _ = conversation_service::update_status(
db,
conv_id,
crate::db::entities::conversation::ConversationStatus::Completed,
)
.await;
}
}
}
"error" => {
let message = payload
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
let agent_type = payload
.get("agent_type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
let mut guard = bridge.lock().await;
if let Some(session) = guard.remove(connection_id) {
let channel_id = session.channel_id;
let sender_id = session.sender_id.clone();
let conv_id = session.conversation_id;
drop(guard);
let lang = get_lang(db).await;
let msg = RichMessage {
title: Some(match lang {
Lang::ZhCn | Lang::ZhTw => "Agent 错误".to_string(),
_ => "Agent Error".to_string(),
}),
body: format!("[{agent_type}] {message}"),
fields: Vec::new(),
level: MessageLevel::Error,
};
let _ = manager.send_to_channel(channel_id, &msg).await;
let _ = conversation_service::update_status(
db,
conv_id,
crate::db::entities::conversation::ConversationStatus::Cancelled,
)
.await;
let _ = sender_context_service::clear_session(db, channel_id, &sender_id).await;
}
}
"status_changed" => {
let status = payload.get("status").and_then(|v| v.as_str()).unwrap_or("");
if status == "disconnected" || status == "error" {
let mut guard = bridge.lock().await;
if let Some(session) = guard.remove(connection_id) {
let channel_id = session.channel_id;
let sender_id = session.sender_id.clone();
drop(guard);
let _ = sender_context_service::clear_session(db, channel_id, &sender_id).await;
}
}
}
_ => {}
}
}
async fn flush_progress(
bridge: &Arc<Mutex<SessionBridge>>,
manager: &ChatChannelManager,
db: &DatabaseConnection,
) {
let lang = get_lang(db).await;
let updates: Vec<(i32, String)> = {
let mut guard = bridge.lock().await;
let mut out = Vec::new();
for session in guard.all_sessions_mut() {
if !session.content_buffer.is_empty()
&& session.last_flushed.elapsed() >= Duration::from_secs(FLUSH_INTERVAL_SECS)
{
session.last_flushed = Instant::now();
let last_tool = session.tool_calls.last().cloned();
let agent_label = session.agent_type.to_string();
let mut status = super::i18n::agent_responding(lang, &agent_label);
if let Some(tool) = last_tool {
status.push_str(&format!(" | {tool}"));
}
out.push((session.channel_id, status));
}
}
out
};
for (channel_id, text) in updates {
let msg = RichMessage::info(text);
let _ = manager.send_to_channel(channel_id, &msg).await;
}
}
fn format_completion(content: &str, tool_count: usize, lang: Lang) -> String {
if content.is_empty() {
return match lang {
Lang::ZhCn | Lang::ZhTw => format!("(无文本输出, {tool_count} 次工具调用)"),
_ => format!("(No text output, {tool_count} tool calls)"),
};
}
if content.len() <= MAX_MESSAGE_LEN {
let mut body = content.to_string();
if tool_count > 0 {
body.push_str(&format!(
"\n\n[{} {}]",
tool_count,
match lang {
Lang::ZhCn | Lang::ZhTw => "次工具调用",
_ => "tool calls",
}
));
}
return body;
}
// Truncate long content (use char boundaries to avoid panic on multi-byte)
let head_end = content
.char_indices()
.nth(500)
.map(|(i, _)| i)
.unwrap_or(content.len());
let head = &content[..head_end];
let tail_start = content
.char_indices()
.rev()
.nth(499)
.map(|(i, _)| i)
.unwrap_or(0);
let tail = &content[tail_start..];
match lang {
Lang::ZhCn | Lang::ZhTw => {
format!(
"{head}\n\n...\n\n{tail}\n\n[完整回复: {} 字符, {tool_count} 次工具调用]",
content.len()
)
}
_ => {
format!(
"{head}\n\n...\n\n{tail}\n\n[Full response: {} chars, {tool_count} tool calls]",
content.len()
)
}
}
}
fn localize_stop_reason(reason: &str, lang: Lang) -> String {
match lang {
Lang::ZhCn => match reason {
"end_turn" => "正常结束",
"cancelled" => "已取消",
"max_tokens" => "达到最大长度",
"stop_sequence" => "遇到停止序列",
"error" => "错误",
"timeout" => "超时",
other => other,
},
Lang::ZhTw => match reason {
"end_turn" => "正常結束",
"cancelled" => "已取消",
"max_tokens" => "達到最大長度",
"stop_sequence" => "遇到停止序列",
"error" => "錯誤",
"timeout" => "逾時",
other => other,
},
Lang::Ja => match reason {
"end_turn" => "正常終了",
"cancelled" => "キャンセル",
"max_tokens" => "最大トークン数到達",
"stop_sequence" => "停止シーケンス",
"error" => "エラー",
"timeout" => "タイムアウト",
other => other,
},
Lang::Ko => match reason {
"end_turn" => "정상 종료",
"cancelled" => "취소됨",
"max_tokens" => "최대 길이 도달",
"stop_sequence" => "정지 시퀀스",
"error" => "오류",
"timeout" => "시간 초과",
other => other,
},
Lang::Es => match reason {
"end_turn" => "Finalizado",
"cancelled" => "Cancelado",
"max_tokens" => "Longitud máxima alcanzada",
"error" => "Error",
"timeout" => "Tiempo agotado",
other => other,
},
Lang::De => match reason {
"end_turn" => "Abgeschlossen",
"cancelled" => "Abgebrochen",
"max_tokens" => "Maximale Länge erreicht",
"error" => "Fehler",
"timeout" => "Zeitüberschreitung",
other => other,
},
Lang::Fr => match reason {
"end_turn" => "Terminé",
"cancelled" => "Annulé",
"max_tokens" => "Longueur maximale atteinte",
"error" => "Erreur",
"timeout" => "Délai dépassé",
other => other,
},
Lang::Pt => match reason {
"end_turn" => "Concluído",
"cancelled" => "Cancelado",
"max_tokens" => "Comprimento máximo atingido",
"error" => "Erro",
"timeout" => "Tempo esgotado",
other => other,
},
Lang::Ar => match reason {
"end_turn" => "اكتمل",
"cancelled" => "ملغى",
"max_tokens" => "تم بلوغ الحد الأقصى",
"error" => "خطأ",
"timeout" => "انتهت المهلة",
other => other,
},
Lang::En => match reason {
"end_turn" => "Completed",
"cancelled" => "Cancelled",
"max_tokens" => "Max length reached",
"stop_sequence" => "Stop sequence",
"error" => "Error",
"timeout" => "Timeout",
other => other,
},
}
.to_string()
}
/// Extract a concise detail string from a tool call's `raw_input` JSON.
///
/// Returns a formatted string like `"Read: src/main.rs"` or `"Bash: npm test"`.
/// Falls back to the original title if no detail can be extracted.
fn format_tool_call_detail(title: &str, raw_input: Option<&str>) -> String {
let parsed = raw_input.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok());
let normalized_title = title.to_lowercase().replace([' ', '-'], "_");
if let Some(ref obj) = parsed {
// File operations: read, edit, write, delete
if let Some(path) = obj
.get("file_path")
.or_else(|| obj.get("path"))
.or_else(|| obj.get("notebook_path"))
.and_then(|v| v.as_str())
{
let short = short_path(path);
let label = match normalized_title.as_str() {
s if s.contains("write") => "Write",
s if s.contains("edit") || s.contains("change") || s.contains("update") => "Edit",
s if s.contains("delete") => "Delete",
_ => "Read",
};
return format!("{label}: {short}");
}
// Bash / shell commands
if let Some(cmd) = obj
.get("command")
.or_else(|| obj.get("cmd"))
.and_then(|v| v.as_str())
{
let short = truncate_str(cmd.lines().next().unwrap_or(cmd), 80);
return format!("Bash: {short}");
}
// Grep / search
if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) {
let path = obj.get("path").and_then(|v| v.as_str());
return if let Some(p) = path {
format!(
"Grep: \"{}\" in {}",
truncate_str(pattern, 40),
short_path(p)
)
} else {
format!("Grep: \"{}\"", truncate_str(pattern, 60))
};
}
// Glob
if let Some(pat) = obj.get("glob").and_then(|v| v.as_str()) {
return format!("Glob: {pat}");
}
// Agent / task
if obj.get("subagent_type").is_some()
|| obj.get("task_id").is_some()
|| obj.get("subject").is_some()
{
let desc = obj
.get("description")
.or_else(|| obj.get("subject"))
.or_else(|| obj.get("prompt"))
.and_then(|v| v.as_str());
if let Some(d) = desc {
return format!("Agent: {}", truncate_str(d, 60));
}
}
// Web fetch
if let Some(url) = obj.get("url").and_then(|v| v.as_str()) {
return format!("Fetch: {}", truncate_str(url, 80));
}
// Web search
if let Some(query) = obj.get("query").and_then(|v| v.as_str()) {
return format!("Search: {}", truncate_str(query, 60));
}
// TodoWrite
if obj.get("todos").is_some() {
return "TodoWrite".to_string();
}
}
// Fallback: if raw_input is a plain string (e.g. a bare command), use it directly
if let Some(raw) = raw_input {
if !raw.starts_with('{') && !raw.starts_with('[') {
let short = truncate_str(raw.lines().next().unwrap_or(raw), 80);
if normalized_title.contains("bash")
|| normalized_title.contains("shell")
|| normalized_title.contains("exec")
{
return format!("Bash: {short}");
}
}
}
title.to_string()
}
fn short_path(path: &str) -> &str {
// Show last 2 path components at most, or the full path if short enough
if path.len() <= 60 {
return path;
}
let parts: Vec<&str> = path.rsplitn(3, '/').collect();
if parts.len() >= 2 {
// e.g. "src/main.rs" from "/very/long/path/src/main.rs"
let tail = &path[path.len() - parts[0].len() - parts[1].len() - 1..];
if tail.len() < path.len() {
return tail;
}
}
path
}
fn truncate_str(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
format!("{truncated}...")
}
}
-53
View File
@@ -1,53 +0,0 @@
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>;
}
-155
View File
@@ -1,155 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelType {
Lark,
Telegram,
Weixin,
}
// ── Per-channel strong typed configs ──
#[derive(Debug, Clone, Deserialize)]
pub struct TelegramConfig {
pub chat_id: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LarkConfig {
pub app_id: String,
pub chat_id: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WeixinConfig {
pub base_url: String,
}
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"),
ChannelType::Weixin => write!(f, "weixin"),
}
}
}
#[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 error(body: impl Into<String>) -> Self {
Self {
title: None,
body: body.into(),
fields: Vec::new(),
level: MessageLevel::Error,
}
}
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
}
}
-597
View File
@@ -1,597 +0,0 @@
use crate::app_error::AppCommandError;
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic};
use crate::chat_channel::manager::ChatChannelManager;
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))
}
#[allow(clippy::too_many_arguments)]
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,
manager: &ChatChannelManager,
id: i32,
) -> Result<(), AppCommandError> {
// Disconnect running backend before deleting from DB (prevents orphaned task)
let _ = manager.remove_channel(id).await;
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 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 token = crate::keyring_store::get_channel_token(id).ok_or_else(|| {
eprintln!("[connect_chat_channel] channel {id}: Token not set in keyring");
AppCommandError::configuration_missing("Token not set")
})?;
eprintln!(
"[connect_chat_channel] channel {id}: creating {channel_type} backend, config={}",
model.config_json
);
let backend = crate::chat_channel::backends::create_backend(id, channel_type, &config, token)
.map_err(AppCommandError::from)?;
manager
.add_channel(id, model.name, channel_type, backend)
.await
.map_err(|e| {
eprintln!("[connect_chat_channel] channel {id}: add_channel failed: {e}");
AppCommandError::from(e)
})?;
eprintln!("[connect_chat_channel] channel {id}: connected successfully");
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
))
})?;
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 token = crate::keyring_store::get_channel_token(id)
.ok_or_else(|| AppCommandError::configuration_missing("Token not set"))?;
let backend = crate::chat_channel::backends::create_backend(id, channel_type, &config, token)
.map_err(AppCommandError::from)?;
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())
}
const COMMAND_PREFIX_KEY: &str = "chat_command_prefix";
const DEFAULT_COMMAND_PREFIX: &str = "/";
pub async fn get_chat_command_prefix_core(db: &AppDatabase) -> Result<String, AppCommandError> {
let val = crate::db::service::app_metadata_service::get_value(&db.conn, COMMAND_PREFIX_KEY)
.await
.map_err(AppCommandError::from)?;
Ok(val.unwrap_or_else(|| DEFAULT_COMMAND_PREFIX.to_string()))
}
pub async fn set_chat_command_prefix_core(
db: &AppDatabase,
prefix: String,
) -> Result<(), AppCommandError> {
let trimmed = prefix.trim();
if trimmed.is_empty() || trimmed.len() > 3 || trimmed.chars().any(|c| c.is_alphanumeric()) {
return Err(AppCommandError::invalid_input(
"Prefix must be 1-3 non-alphanumeric characters",
));
}
crate::db::service::app_metadata_service::upsert_value(&db.conn, COMMAND_PREFIX_KEY, trimmed)
.await
.map_err(AppCommandError::from)?;
Ok(())
}
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
pub async fn get_chat_message_language_core(db: &AppDatabase) -> Result<String, AppCommandError> {
let val = crate::db::service::app_metadata_service::get_value(&db.conn, MESSAGE_LANGUAGE_KEY)
.await
.map_err(AppCommandError::from)?;
Ok(val.unwrap_or_else(|| "en".to_string()))
}
pub async fn set_chat_message_language_core(
db: &AppDatabase,
language: String,
) -> Result<(), AppCommandError> {
// Validate language code
let valid = [
"en", "zh-cn", "zh-tw", "ja", "ko", "es", "de", "fr", "pt", "ar",
];
let lang_lower = language.to_lowercase();
if !valid.contains(&lang_lower.as_str()) {
return Err(AppCommandError::invalid_input(format!(
"Unsupported language: {language}. Supported: {}",
valid.join(", ")
)));
}
crate::db::service::app_metadata_service::upsert_value(
&db.conn,
MESSAGE_LANGUAGE_KEY,
&lang_lower,
)
.await
.map_err(AppCommandError::from)?;
Ok(())
}
const EVENT_FILTER_KEY: &str = "chat_event_filter";
pub async fn get_chat_event_filter_core(
db: &AppDatabase,
) -> Result<Option<Vec<String>>, AppCommandError> {
let val = crate::db::service::app_metadata_service::get_value(&db.conn, EVENT_FILTER_KEY)
.await
.map_err(AppCommandError::from)?;
match val {
Some(json) => {
// Parse as Option<Vec<String>> to correctly handle stored "null"
let filter: Option<Vec<String>> = serde_json::from_str(&json)
.map_err(|e| AppCommandError::invalid_input(e.to_string()))?;
Ok(filter)
}
None => Ok(None),
}
}
pub async fn set_chat_event_filter_core(
db: &AppDatabase,
filter: Option<Vec<String>>,
) -> Result<(), AppCommandError> {
match filter {
Some(arr) => {
let json = serde_json::to_string(&arr)
.map_err(|e| AppCommandError::invalid_input(e.to_string()))?;
crate::db::service::app_metadata_service::upsert_value(
&db.conn,
EVENT_FILTER_KEY,
&json,
)
.await
.map_err(AppCommandError::from)?;
}
None => {
// null means all events enabled — remove the key
crate::db::service::app_metadata_service::upsert_value(
&db.conn,
EVENT_FILTER_KEY,
"null",
)
.await
.map_err(AppCommandError::from)?;
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// WeChat QR code auth
// ---------------------------------------------------------------------------
pub async fn weixin_get_qrcode_core() -> Result<WeixinQrcodeInfo, AppCommandError> {
crate::chat_channel::backends::weixin::weixin_get_qrcode()
.await
.map_err(AppCommandError::from)
}
pub async fn weixin_check_qrcode_core(
db: &AppDatabase,
channel_id: i32,
qrcode: &str,
) -> Result<WeixinQrcodeStatusPublic, AppCommandError> {
let result = crate::chat_channel::backends::weixin::weixin_check_qrcode(qrcode)
.await
.map_err(AppCommandError::from)?;
// On confirmed: save token + update config with base_url
if result.status == "confirmed" {
eprintln!(
"[Weixin] QR confirmed for channel {channel_id}, bot_token={}, base_url={}",
result
.bot_token
.as_deref()
.map(|t| if t.len() > 8 { &t[..8] } else { t })
.unwrap_or("None"),
result.base_url.as_deref().unwrap_or("None"),
);
if let Some(ref token) = result.bot_token {
save_chat_channel_token_core(channel_id, token)?;
eprintln!("[Weixin] Token saved for channel {channel_id}");
} else {
eprintln!(
"[Weixin] WARNING: No bot_token in confirmed response for channel {channel_id}"
);
}
if let Some(ref base_url) = result.base_url {
let config_json = serde_json::json!({ "base_url": base_url }).to_string();
update_chat_channel_core(
db,
channel_id,
None,
None,
Some(config_json),
None,
None,
None,
)
.await?;
eprintln!("[Weixin] Config updated with base_url for channel {channel_id}");
}
}
// Return only the status — never expose bot_token to the frontend
Ok(WeixinQrcodeStatusPublic {
status: result.status,
})
}
// ---------------------------------------------------------------------------
// 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
}
#[allow(clippy::too_many_arguments)]
#[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>,
manager: tauri::State<'_, ChatChannelManager>,
id: i32,
) -> Result<(), AppCommandError> {
delete_chat_channel_core(&db, &manager, 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
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn get_chat_command_prefix(
db: tauri::State<'_, AppDatabase>,
) -> Result<String, AppCommandError> {
get_chat_command_prefix_core(&db).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn set_chat_command_prefix(
db: tauri::State<'_, AppDatabase>,
prefix: String,
) -> Result<(), AppCommandError> {
set_chat_command_prefix_core(&db, prefix).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn get_chat_event_filter(
db: tauri::State<'_, AppDatabase>,
) -> Result<Option<Vec<String>>, AppCommandError> {
get_chat_event_filter_core(&db).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn set_chat_event_filter(
db: tauri::State<'_, AppDatabase>,
filter: Option<Vec<String>>,
) -> Result<(), AppCommandError> {
set_chat_event_filter_core(&db, filter).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn get_chat_message_language(
db: tauri::State<'_, AppDatabase>,
) -> Result<String, AppCommandError> {
get_chat_message_language_core(&db).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn set_chat_message_language(
db: tauri::State<'_, AppDatabase>,
language: String,
) -> Result<(), AppCommandError> {
set_chat_message_language_core(&db, language).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, AppCommandError> {
weixin_get_qrcode_core().await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn weixin_check_qrcode(
db: tauri::State<'_, AppDatabase>,
channel_id: i32,
qrcode: String,
) -> Result<WeixinQrcodeStatusPublic, AppCommandError> {
weixin_check_qrcode_core(&db, channel_id, &qrcode).await
}
-2
View File
@@ -1,5 +1,4 @@
pub mod acp;
pub mod chat_channel;
pub mod conversations;
pub mod experts;
pub mod folder_commands;
@@ -8,7 +7,6 @@ pub mod mcp;
pub mod model_provider;
#[cfg(feature = "tauri-runtime")]
pub mod notification;
pub mod project_boot;
pub mod quick_messages;
pub mod system_settings;
pub mod terminal;
-184
View File
@@ -1,184 +0,0 @@
use serde::Serialize;
use std::path::PathBuf;
use crate::app_error::AppCommandError;
// ---------------------------------------------------------------------------
// Package manager detection
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct PackageManagerInfo {
pub name: String,
pub installed: bool,
pub version: Option<String>,
}
async fn detect_one(name: &str) -> PackageManagerInfo {
let program = match name {
"bun" => "bun",
"pnpm" => "pnpm",
"yarn" => "yarn",
_ => "npm",
};
let result = crate::process::tokio_command(program)
.arg("--version")
.output()
.await;
match result {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
PackageManagerInfo {
name: name.to_string(),
installed: true,
version: Some(version),
}
}
_ => PackageManagerInfo {
name: name.to_string(),
installed: false,
version: None,
},
}
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn detect_package_manager(name: String) -> PackageManagerInfo {
detect_one(&name).await
}
// ---------------------------------------------------------------------------
// Project creation
// ---------------------------------------------------------------------------
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn create_shadcn_project(
project_name: String,
template: String,
preset_code: String,
package_manager: String,
target_dir: String,
) -> Result<String, AppCommandError> {
let project_name = project_name.trim().to_string();
let template = template.trim().to_string();
let preset_code = preset_code.trim().to_string();
let package_manager = package_manager.trim().to_string();
let target_dir = target_dir.trim().to_string();
if project_name.is_empty() {
return Err(AppCommandError::invalid_input("Project name is required"));
}
if template.is_empty() {
return Err(AppCommandError::invalid_input("Template is required"));
}
if target_dir.is_empty() {
return Err(AppCommandError::invalid_input(
"Target directory is required",
));
}
let full_path = PathBuf::from(&target_dir).join(&project_name);
let full_path_str = full_path.to_string_lossy().to_string();
// Check if directory already exists and is non-empty
if full_path.exists() {
let is_empty = full_path
.read_dir()
.map(|mut entries| entries.next().is_none())
.unwrap_or(false);
if !is_empty {
return Err(AppCommandError::already_exists(
"Target directory already exists and is not empty",
));
}
}
// Determine the command based on package manager
let (program, prefix_args): (&str, Vec<&str>) = match package_manager.as_str() {
"pnpm" => ("pnpm", vec!["dlx"]),
"yarn" => ("yarn", vec!["dlx"]),
"bun" => ("bunx", vec![]),
_ => ("npx", vec![]),
};
let mut cmd = crate::process::tokio_command(program);
cmd.args(&prefix_args);
cmd.args([
"shadcn@latest",
"init",
"-n",
&project_name,
"-t",
&template,
"-p",
&preset_code,
"-y",
]);
cmd.current_dir(&target_dir);
// Log the full command for debugging
let cmd_display = format!(
"{} {} shadcn@latest init -n {} -t {} -p {} -y (cwd={})",
program,
prefix_args.join(" "),
project_name,
template,
preset_code,
target_dir
);
eprintln!("[ProjectBoot] executing: {cmd_display}");
let output = cmd.output().await.map_err(|e| {
eprintln!("[ProjectBoot] spawn error: {e}");
if e.kind() == std::io::ErrorKind::NotFound {
AppCommandError::dependency_missing(format!(
"{program} is not installed. Please install Node.js first."
))
} else {
AppCommandError::external_command(
"Failed to execute project creation command",
e.to_string(),
)
}
})?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
eprintln!(
"[ProjectBoot] exit={} stdout_len={} stderr_len={}",
output.status,
stdout.len(),
stderr.len()
);
if !stdout.is_empty() {
eprintln!("[ProjectBoot] stdout: {stdout}");
}
if !stderr.is_empty() {
eprintln!("[ProjectBoot] stderr: {stderr}");
}
if !output.status.success() {
let mut detail = String::new();
if !stderr.is_empty() {
detail.push_str(&stderr);
}
if !stdout.is_empty() {
if !detail.is_empty() {
detail.push('\n');
}
detail.push_str(&stdout);
}
if detail.is_empty() {
detail = format!("Command exited with status: {}", output.status);
}
return Err(AppCommandError::external_command(
"Project creation command failed",
detail,
));
}
Ok(full_path_str)
}
-31
View File
@@ -1,31 +0,0 @@
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 {}
@@ -1,33 +0,0 @@
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,35 +0,0 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "chat_channel_sender_context")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub channel_id: i32,
pub sender_id: String,
pub current_folder_id: Option<i32>,
pub current_agent_type: Option<String>,
pub current_conversation_id: Option<i32>,
pub current_connection_id: Option<String>,
pub auto_approve: bool,
pub created_at: DateTimeUtc,
pub updated_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 {}
-3
View File
@@ -1,8 +1,5 @@
pub mod agent_setting;
pub mod app_metadata;
pub mod chat_channel;
pub mod chat_channel_message_log;
pub mod chat_channel_sender_context;
pub mod conversation;
pub mod folder;
pub mod folder_command;
-3
View File
@@ -2,9 +2,6 @@
pub use super::agent_setting::Entity as AgentSetting;
pub use super::app_metadata::Entity as AppMetadata;
pub use super::chat_channel::Entity as ChatChannel;
pub use super::chat_channel_message_log::Entity as ChatChannelMessageLog;
pub use super::chat_channel_sender_context::Entity as ChatChannelSenderContext;
pub use super::conversation::Entity as Conversation;
pub use super::folder::Entity as Folder;
pub use super::folder_command::Entity as FolderCommand;
@@ -1,70 +0,0 @@
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])
}
}
@@ -1,99 +0,0 @@
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?)
}
#[allow(clippy::too_many_arguments)]
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?)
}
-3
View File
@@ -1,12 +1,9 @@
pub mod agent_setting_service;
pub mod app_metadata_service;
pub mod chat_channel_message_log_service;
pub mod chat_channel_service;
pub mod conversation_service;
pub mod folder_command_service;
pub mod folder_service;
pub mod import_service;
pub mod model_provider_service;
pub mod quick_message_service;
pub mod sender_context_service;
pub mod tab_service;
@@ -1,101 +0,0 @@
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
IntoActiveModel, QueryFilter, Set,
};
use crate::db::entities::chat_channel_sender_context;
use crate::db::error::DbError;
pub async fn get_or_create(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
) -> Result<chat_channel_sender_context::Model, DbError> {
let existing = chat_channel_sender_context::Entity::find()
.filter(chat_channel_sender_context::Column::ChannelId.eq(channel_id))
.filter(chat_channel_sender_context::Column::SenderId.eq(sender_id))
.one(conn)
.await?;
if let Some(model) = existing {
return Ok(model);
}
let now = Utc::now();
let active = chat_channel_sender_context::ActiveModel {
id: NotSet,
channel_id: Set(channel_id),
sender_id: Set(sender_id.to_string()),
current_folder_id: Set(None),
current_agent_type: Set(None),
current_conversation_id: Set(None),
current_connection_id: Set(None),
auto_approve: Set(false),
created_at: Set(now),
updated_at: Set(now),
};
Ok(active.insert(conn).await?)
}
pub async fn update_folder(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
folder_id: Option<i32>,
) -> Result<chat_channel_sender_context::Model, DbError> {
let model = get_or_create(conn, channel_id, sender_id).await?;
let mut active = model.into_active_model();
active.current_folder_id = Set(folder_id);
active.updated_at = Set(Utc::now());
Ok(active.update(conn).await?)
}
pub async fn update_agent(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
agent_type: Option<String>,
) -> Result<chat_channel_sender_context::Model, DbError> {
let model = get_or_create(conn, channel_id, sender_id).await?;
let mut active = model.into_active_model();
active.current_agent_type = Set(agent_type);
active.updated_at = Set(Utc::now());
Ok(active.update(conn).await?)
}
pub async fn update_session(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
conversation_id: Option<i32>,
connection_id: Option<String>,
) -> Result<chat_channel_sender_context::Model, DbError> {
let model = get_or_create(conn, channel_id, sender_id).await?;
let mut active = model.into_active_model();
active.current_conversation_id = Set(conversation_id);
active.current_connection_id = Set(connection_id);
active.updated_at = Set(Utc::now());
Ok(active.update(conn).await?)
}
pub async fn clear_session(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
) -> Result<chat_channel_sender_context::Model, DbError> {
update_session(conn, channel_id, sender_id, None, None).await
}
pub async fn update_auto_approve(
conn: &DatabaseConnection,
channel_id: i32,
sender_id: &str,
auto_approve: bool,
) -> Result<chat_channel_sender_context::Model, DbError> {
let model = get_or_create(conn, channel_id, sender_id).await?;
let mut active = model.into_active_model();
active.auto_approve = Set(auto_approve);
active.updated_at = Set(Utc::now());
Ok(active.update(conn).await?)
}
-50
View File
@@ -5,9 +5,6 @@ fn token_key(account_id: &str) -> String {
format!("github-token:{}", account_id)
}
fn channel_token_key(channel_id: i32) -> String {
format!("chat-channel:{}", channel_id)
}
// ── Tauri mode: OS keyring ──
@@ -91,50 +88,3 @@ pub fn delete_token(account_id: &str) -> Result<(), String> {
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)
}
+3 -79
View File
@@ -1,7 +1,6 @@
mod acp;
mod app_error;
pub mod app_state;
pub mod chat_channel;
pub mod commands;
pub mod db;
pub mod git_credential;
@@ -30,11 +29,10 @@ mod tauri_app {
use std::sync::atomic::{AtomicBool, Ordering};
use crate::acp::manager::ConnectionManager;
use crate::chat_channel::manager::ChatChannelManager;
use crate::commands::{
acp as acp_commands, chat_channel as chat_channel_commands, conversations,
acp as acp_commands, conversations,
experts as experts_commands, folder_commands, folders, mcp as mcp_commands,
model_provider as model_provider_commands, notification, project_boot,
model_provider as model_provider_commands, notification,
quick_messages as quick_messages_commands, system_settings,
terminal as terminal_commands, version_control, windows,
workspace_state as workspace_state_commands,
@@ -106,10 +104,7 @@ mod tauri_app {
.plugin(tauri_plugin_notification::init())
.manage(ConnectionManager::new())
.manage(TerminalManager::new())
.manage(ChatChannelManager::new())
.manage(windows::SettingsWindowState::new())
.manage(windows::CommitWindowState::new())
.manage(windows::MergeWindowState::new())
.manage(web::WebServerState::new())
.manage(std::sync::Arc::new(
web::event_bridge::WebEventBroadcaster::new(),
@@ -170,22 +165,7 @@ 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();
let cm = app.state::<ConnectionManager>().clone_ref();
let emitter = web::event_bridge::EventEmitter::Tauri(app.handle().clone());
tauri::async_runtime::spawn(async move {
ccm_ref.start_background(br, db_conn, cm, emitter).await;
});
}
// Single-window workspace: ensure the main window exists.
// Single-window workspace: ensure the main window exists.
// Workspace state (open folders, opened tabs, active tab) is
// restored by the frontend via `list_open_folder_details` /
// `list_opened_tabs` inside the main window.
@@ -217,35 +197,6 @@ mod tauri_app {
}
}
if label.starts_with("commit-")
&& matches!(
event,
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
)
{
let app = window.app_handle();
if let Some(state) = app.try_state::<windows::CommitWindowState>() {
windows::restore_window_after_commit(app, &state, &label);
}
}
if label.starts_with("merge-")
&& matches!(
event,
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
)
{
let app = window.app_handle();
if let Some(state) = app.try_state::<windows::MergeWindowState>() {
windows::restore_window_after_merge(app, &state, &label);
}
let app_clone = window.app_handle().clone();
let label_clone = label.clone();
tauri::async_runtime::spawn(async move {
windows::cleanup_dangling_merge(&app_clone, &label_clone).await;
});
}
if label == "main" && matches!(event, tauri::WindowEvent::CloseRequested { .. }) {
let app = window.app_handle();
if let Some(cm) = app.try_state::<ConnectionManager>() {
@@ -352,16 +303,9 @@ mod tauri_app {
folders::git_log,
folders::git_commit_branches,
windows::open_folder_window,
windows::open_commit_window,
windows::open_settings_window,
windows::open_merge_window,
windows::open_stash_window,
windows::open_push_window,
windows::open_project_boot_window,
windows::update_traffic_light_position,
windows::update_appearance_mode,
project_boot::detect_package_manager,
project_boot::create_shadcn_project,
system_settings::get_system_proxy_settings,
system_settings::update_system_proxy_settings,
system_settings::get_system_language_settings,
@@ -440,26 +384,6 @@ mod tauri_app {
mcp_commands::mcp_set_server_apps,
mcp_commands::mcp_remove_server,
notification::send_notification,
chat_channel_commands::list_chat_channels,
chat_channel_commands::create_chat_channel,
chat_channel_commands::update_chat_channel,
chat_channel_commands::delete_chat_channel,
chat_channel_commands::save_chat_channel_token,
chat_channel_commands::get_chat_channel_has_token,
chat_channel_commands::delete_chat_channel_token,
chat_channel_commands::connect_chat_channel,
chat_channel_commands::disconnect_chat_channel,
chat_channel_commands::test_chat_channel,
chat_channel_commands::get_chat_channel_status,
chat_channel_commands::list_chat_channel_messages,
chat_channel_commands::get_chat_command_prefix,
chat_channel_commands::set_chat_command_prefix,
chat_channel_commands::get_chat_event_filter,
chat_channel_commands::set_chat_event_filter,
chat_channel_commands::get_chat_message_language,
chat_channel_commands::set_chat_message_language,
chat_channel_commands::weixin_get_qrcode,
chat_channel_commands::weixin_check_qrcode,
model_provider_commands::list_model_providers,
model_provider_commands::create_model_provider,
model_provider_commands::update_model_provider,
-67
View File
@@ -1,67 +0,0 @@
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(),
}
}
}
-2
View File
@@ -1,5 +1,4 @@
pub mod agent;
pub mod chat_channel;
pub mod conversation;
pub mod folder;
pub mod message;
@@ -9,7 +8,6 @@ pub mod system;
pub use agent::AgentType;
#[allow(unused_imports)]
pub use chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
pub use conversation::{
AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary,
DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats,
-274
View File
@@ -1,274 +0,0 @@
use std::sync::Arc;
use axum::{extract::Extension, Json};
use serde::Deserialize;
use crate::app_error::AppCommandError;
use crate::app_state::AppState;
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic};
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, &state.chat_channel_manager, 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, &params.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))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetCommandPrefixParams {
pub prefix: String,
}
pub async fn get_chat_command_prefix(
Extension(state): Extension<Arc<AppState>>,
) -> Result<Json<String>, AppCommandError> {
let result = cc_commands::get_chat_command_prefix_core(&state.db).await?;
Ok(Json(result))
}
pub async fn set_chat_command_prefix(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<SetCommandPrefixParams>,
) -> Result<Json<()>, AppCommandError> {
cc_commands::set_chat_command_prefix_core(&state.db, params.prefix).await?;
Ok(Json(()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetEventFilterParams {
pub filter: Option<Vec<String>>,
}
pub async fn get_chat_event_filter(
Extension(state): Extension<Arc<AppState>>,
) -> Result<Json<Option<Vec<String>>>, AppCommandError> {
let result = cc_commands::get_chat_event_filter_core(&state.db).await?;
Ok(Json(result))
}
pub async fn set_chat_event_filter(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<SetEventFilterParams>,
) -> Result<Json<()>, AppCommandError> {
cc_commands::set_chat_event_filter_core(&state.db, params.filter).await?;
Ok(Json(()))
}
pub async fn get_chat_message_language(
Extension(state): Extension<Arc<AppState>>,
) -> Result<Json<String>, AppCommandError> {
let result = cc_commands::get_chat_message_language_core(&state.db).await?;
Ok(Json(result))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetMessageLanguageParams {
pub language: String,
}
pub async fn set_chat_message_language(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<SetMessageLanguageParams>,
) -> Result<Json<()>, AppCommandError> {
cc_commands::set_chat_message_language_core(&state.db, params.language).await?;
Ok(Json(()))
}
// ---------------------------------------------------------------------------
// WeChat QR code auth
// ---------------------------------------------------------------------------
pub async fn weixin_get_qrcode() -> Result<Json<WeixinQrcodeInfo>, AppCommandError> {
let result = cc_commands::weixin_get_qrcode_core().await?;
Ok(Json(result))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WeixinCheckQrcodeParams {
pub channel_id: i32,
pub qrcode: String,
}
pub async fn weixin_check_qrcode(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<WeixinCheckQrcodeParams>,
) -> Result<Json<WeixinQrcodeStatusPublic>, AppCommandError> {
let result =
cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, &params.qrcode).await?;
Ok(Json(result))
}
-2
View File
@@ -1,5 +1,4 @@
pub mod acp;
pub mod chat_channel;
pub mod conversations;
mod error;
pub mod experts;
@@ -9,7 +8,6 @@ pub mod folders;
pub mod git;
pub mod mcp;
pub mod model_provider;
pub mod project_boot;
pub mod quick_messages;
pub mod system_settings;
pub mod terminal;
@@ -1,49 +0,0 @@
use axum::Json;
use serde::Deserialize;
use crate::app_error::AppCommandError;
use crate::commands::project_boot as pb_commands;
// ---------------------------------------------------------------------------
// Param structs
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct DetectPackageManagerParams {
pub name: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateShadcnProjectParams {
pub project_name: String,
pub template: String,
pub preset_code: String,
pub package_manager: String,
pub target_dir: String,
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
pub async fn detect_package_manager(
Json(params): Json<DetectPackageManagerParams>,
) -> Json<pb_commands::PackageManagerInfo> {
let info = pb_commands::detect_package_manager(params.name).await;
Json(info)
}
pub async fn create_shadcn_project(
Json(params): Json<CreateShadcnProjectParams>,
) -> Result<Json<String>, AppCommandError> {
let result = pb_commands::create_shadcn_project(
params.project_name,
params.template,
params.preset_code,
params.package_manager,
params.target_dir,
)
.await?;
Ok(Json(result))
}
-1
View File
@@ -458,7 +458,6 @@ pub async fn start_web_server(
emitter: crate::web::event_bridge::EventEmitter::Tauri(app.clone()),
data_dir: app.path().app_data_dir().unwrap_or_default(),
web_server_state: WebServerState::new(), // placeholder; not used by handlers
chat_channel_manager: crate::app_state::default_chat_channel_manager(),
});
let router = router::build_router(app_state, token.clone(), static_dir);
-106
View File
@@ -150,22 +150,6 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
"/open_settings_window",
post(handlers::folders::open_settings_window),
)
.route(
"/open_commit_window",
post(handlers::folders::open_commit_window),
)
.route(
"/open_merge_window",
post(handlers::folders::open_merge_window),
)
.route(
"/open_stash_window",
post(handlers::folders::open_stash_window),
)
.route(
"/open_push_window",
post(handlers::folders::open_push_window),
)
// ─── Git (pure) ───
.route("/git_status", post(handlers::git::git_status))
.route("/git_init", post(handlers::git::git_init))
@@ -507,15 +491,6 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
"/experts_open_central_dir",
post(handlers::experts::experts_open_central_dir),
)
// ─── Project boot ───
.route(
"/detect_package_manager",
post(handlers::project_boot::detect_package_manager),
)
.route(
"/create_shadcn_project",
post(handlers::project_boot::create_shadcn_project),
)
// ─── Web Server ───
.route(
"/get_web_server_status",
@@ -533,87 +508,6 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
"/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),
)
.route(
"/get_chat_command_prefix",
post(handlers::chat_channel::get_chat_command_prefix),
)
.route(
"/set_chat_command_prefix",
post(handlers::chat_channel::set_chat_command_prefix),
)
.route(
"/get_chat_event_filter",
post(handlers::chat_channel::get_chat_event_filter),
)
.route(
"/set_chat_event_filter",
post(handlers::chat_channel::set_chat_event_filter),
)
.route(
"/get_chat_message_language",
post(handlers::chat_channel::get_chat_message_language),
)
.route(
"/set_chat_message_language",
post(handlers::chat_channel::set_chat_message_language),
)
.route(
"/weixin_get_qrcode",
post(handlers::chat_channel::weixin_get_qrcode),
)
.route(
"/weixin_check_qrcode",
post(handlers::chat_channel::weixin_check_qrcode),
)
// ─── Model Providers ───
.route(
"/list_model_providers",