初步集成消息通道,支持Telegram + Lark机器人

This commit is contained in:
xintaofei
2026-03-30 22:51:49 +08:00
parent 544abbd15d
commit d18cec33bf
44 changed files with 4106 additions and 11 deletions

63
src-tauri/Cargo.lock generated
View File

@@ -386,7 +386,7 @@ dependencies = [
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.28.0",
"tower",
"tower-layer",
"tower-service",
@@ -850,6 +850,7 @@ name = "codeg"
version = "0.5.3"
dependencies = [
"agent-client-protocol-schema",
"async-trait",
"axum",
"base64 0.22.1",
"bzip2",
@@ -858,11 +859,13 @@ dependencies = [
"fix-path-env",
"flate2",
"futures",
"futures-util",
"keyring",
"kill_tree",
"mac-notification-sys",
"notify",
"portable-pty",
"prost",
"regex",
"reqwest 0.12.28",
"sacp",
@@ -882,6 +885,7 @@ dependencies = [
"tauri-plugin-window-state",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.26.2",
"toml 0.8.2",
"tower-http",
"urlencoding",
@@ -4292,6 +4296,29 @@ dependencies = [
"yansi",
]
[[package]]
name = "prost"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
@@ -6731,6 +6758,20 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
"native-tls",
"tokio",
"tokio-native-tls",
"tungstenite 0.26.2",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
@@ -6740,7 +6781,7 @@ dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
"tungstenite 0.28.0",
]
[[package]]
@@ -6984,6 +7025,24 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"native-tls",
"rand 0.9.2",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.28.0"

View File

@@ -46,6 +46,7 @@ tauri-build = { version = "2", features = [], optional = true }
tauri = { version = "2", features = [], optional = true }
tauri-plugin-opener = { version = "2", optional = true }
tauri-plugin-dialog = { version = "2", optional = true }
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
@@ -77,6 +78,9 @@ which = "7"
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"], optional = true }
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["fs", "cors"] }
tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
futures-util = "0.3"
prost = "0.13"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-window-state = { version = "2", optional = true }

View File

@@ -2,6 +2,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use crate::acp::manager::ConnectionManager;
use crate::chat_channel::manager::ChatChannelManager;
use crate::db::AppDatabase;
use crate::terminal::manager::TerminalManager;
use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster};
@@ -15,6 +16,7 @@ pub struct AppState {
pub emitter: EventEmitter,
pub data_dir: PathBuf,
pub web_server_state: WebServerState,
pub chat_channel_manager: ChatChannelManager,
}
pub fn default_connection_manager() -> ConnectionManager {
@@ -24,3 +26,7 @@ pub fn default_connection_manager() -> ConnectionManager {
pub fn default_terminal_manager() -> TerminalManager {
TerminalManager::new()
}
pub fn default_chat_channel_manager() -> ChatChannelManager {
ChatChannelManager::new()
}

View File

@@ -45,8 +45,15 @@ async fn main() {
emitter,
data_dir,
web_server_state: WebServerState::new(),
chat_channel_manager: codeg_lib::app_state::default_chat_channel_manager(),
});
// Start chat channel background tasks (event subscriber, command dispatcher, scheduler, auto-connect)
state
.chat_channel_manager
.start_background(state.event_broadcaster.clone(), state.db.conn.clone())
.await;
// Build router
let router = codeg_lib::web::router::build_router(state, token.clone(), static_dir);

View File

@@ -0,0 +1,622 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use futures_util::{SinkExt, StreamExt};
use prost::Message as ProstMessage;
use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, Mutex, RwLock};
use tokio_tungstenite::tungstenite;
use crate::chat_channel::error::ChatChannelError;
use crate::chat_channel::traits::ChatChannelBackend;
use crate::chat_channel::types::*;
const FEISHU_BASE_URL: &str = "https://open.feishu.cn";
const TOKEN_REFRESH_MARGIN_SECS: u64 = 300;
// ── Lark WebSocket protobuf Frame (pbbp2) ──
// Source: larksuite/oapi-sdk-go ws/pbbp2.pb.go
const FRAME_METHOD_CONTROL: i32 = 0; // Ping/Pong
const FRAME_METHOD_DATA: i32 = 1; // Event/Card
#[derive(Clone, PartialEq, ProstMessage)]
struct Frame {
#[prost(uint64, tag = 1)]
seq_id: u64,
#[prost(uint64, tag = 2)]
log_id: u64,
#[prost(int32, tag = 3)]
service: i32,
#[prost(int32, tag = 4)]
method: i32,
#[prost(message, repeated, tag = 5)]
headers: Vec<FrameHeader>,
#[prost(string, tag = 6)]
payload_encoding: String,
#[prost(string, tag = 7)]
payload_type: String,
#[prost(bytes = "vec", tag = 8)]
payload: Vec<u8>,
#[prost(string, tag = 9)]
log_id_new: String,
}
#[derive(Clone, PartialEq, ProstMessage)]
struct FrameHeader {
#[prost(string, tag = 1)]
key: String,
#[prost(string, tag = 2)]
value: String,
}
impl Frame {
fn get_header(&self, key: &str) -> Option<&str> {
self.headers
.iter()
.find(|h| h.key == key)
.map(|h| h.value.as_str())
}
fn set_header(&mut self, key: &str, value: &str) {
if let Some(h) = self.headers.iter_mut().find(|h| h.key == key) {
h.value = value.to_string();
} else {
self.headers.push(FrameHeader {
key: key.to_string(),
value: value.to_string(),
});
}
}
}
// ── Lark REST API types ──
#[derive(Deserialize)]
struct TenantAccessTokenResponse {
code: i32,
msg: String,
tenant_access_token: Option<String>,
expire: Option<u64>,
}
#[derive(Serialize)]
struct SendMessageRequest {
receive_id: String,
msg_type: String,
content: String,
}
#[derive(Deserialize)]
struct SendMessageResponse {
code: i32,
msg: String,
data: Option<SendMessageData>,
}
#[derive(Deserialize)]
struct SendMessageData {
message_id: Option<String>,
}
#[derive(Deserialize)]
struct WsConnectResponse {
code: i32,
msg: String,
data: Option<WsConnectData>,
}
#[derive(Deserialize)]
struct WsConnectData {
#[serde(rename = "URL")]
url: Option<String>,
}
// ── Token cache ──
struct TokenCache {
token: String,
expires_at: Instant,
}
// ── Multi-part frame cache ──
struct PartialMessage {
parts: HashMap<i32, Vec<u8>>,
total: i32,
}
// ── LarkBackend ──
pub struct LarkBackend {
app_id: String,
app_secret: String,
chat_id: String,
channel_id: i32,
client: reqwest::Client,
token_cache: Arc<RwLock<Option<TokenCache>>>,
status: Arc<Mutex<ChannelConnectionStatus>>,
shutdown_tx: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
}
impl LarkBackend {
pub fn new(channel_id: i32, app_id: String, app_secret: String, chat_id: String) -> Self {
Self {
app_id,
app_secret,
chat_id,
channel_id,
client: reqwest::Client::new(),
token_cache: Arc::new(RwLock::new(None)),
status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)),
shutdown_tx: Arc::new(Mutex::new(None)),
}
}
async fn get_tenant_access_token(&self) -> Result<String, ChatChannelError> {
{
let cache = self.token_cache.read().await;
if let Some(cached) = cache.as_ref() {
if cached.expires_at > Instant::now() {
return Ok(cached.token.clone());
}
}
}
let resp = self
.client
.post(format!(
"{}/open-apis/auth/v3/tenant_access_token/internal",
FEISHU_BASE_URL
))
.json(&serde_json::json!({
"app_id": self.app_id,
"app_secret": self.app_secret,
}))
.send()
.await
.map_err(|e| ChatChannelError::AuthenticationFailed(e.to_string()))?;
let result: TenantAccessTokenResponse = resp
.json()
.await
.map_err(|e| ChatChannelError::AuthenticationFailed(e.to_string()))?;
if result.code != 0 {
return Err(ChatChannelError::AuthenticationFailed(format!(
"code={}, msg={}",
result.code, result.msg
)));
}
let token = result
.tenant_access_token
.ok_or_else(|| {
ChatChannelError::AuthenticationFailed("No token in response".into())
})?;
let expire_secs = result.expire.unwrap_or(7200);
let expires_at = Instant::now()
+ Duration::from_secs(expire_secs.saturating_sub(TOKEN_REFRESH_MARGIN_SECS));
*self.token_cache.write().await = Some(TokenCache {
token: token.clone(),
expires_at,
});
Ok(token)
}
async fn send_lark_message(
&self,
msg_type: &str,
content: &str,
) -> Result<SentMessageId, ChatChannelError> {
let token = self.get_tenant_access_token().await?;
let resp = self
.client
.post(format!(
"{}/open-apis/im/v1/messages?receive_id_type=chat_id",
FEISHU_BASE_URL
))
.header("Authorization", format!("Bearer {}", token))
.json(&SendMessageRequest {
receive_id: self.chat_id.clone(),
msg_type: msg_type.to_string(),
content: content.to_string(),
})
.send()
.await
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
let result: SendMessageResponse = resp
.json()
.await
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
if result.code != 0 {
return Err(ChatChannelError::SendFailed(format!(
"code={}, msg={}",
result.code, result.msg
)));
}
let message_id = result
.data
.and_then(|d| d.message_id)
.unwrap_or_default();
Ok(SentMessageId(message_id))
}
async fn start_ws_receiver(
&self,
command_tx: mpsc::Sender<IncomingCommand>,
) -> Result<(), ChatChannelError> {
// Verify we can get a WS URL before spawning the background task
let _ = fetch_ws_url(&self.client, &self.app_id, &self.app_secret).await?;
let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
*self.shutdown_tx.lock().await = Some(shutdown_tx);
let channel_id = self.channel_id;
let status = self.status.clone();
let app_id = self.app_id.clone();
let app_secret = self.app_secret.clone();
let client = self.client.clone();
tokio::spawn(async move {
let mut retry_count = 0u32;
loop {
if *shutdown_rx.borrow() {
break;
}
let ws_url = match fetch_ws_url(&client, &app_id, &app_secret).await {
Ok(url) => url,
Err(e) => {
eprintln!("[Lark] failed to get WS endpoint: {e}");
*status.lock().await = ChannelConnectionStatus::Error;
let delay = Duration::from_secs((2u64).pow(retry_count.min(5)));
retry_count += 1;
tokio::select! {
_ = tokio::time::sleep(delay) => continue,
_ = shutdown_rx.changed() => break,
}
}
};
let ws_result = tokio_tungstenite::connect_async(&ws_url).await;
let ws_stream = match ws_result {
Ok((stream, _)) => {
*status.lock().await = ChannelConnectionStatus::Connected;
retry_count = 0;
eprintln!("[Lark] WebSocket connected");
stream
}
Err(e) => {
eprintln!("[Lark] WebSocket connect failed: {e}");
*status.lock().await = ChannelConnectionStatus::Error;
let delay = Duration::from_secs((2u64).pow(retry_count.min(5)));
retry_count += 1;
tokio::select! {
_ = tokio::time::sleep(delay) => continue,
_ = shutdown_rx.changed() => break,
}
}
};
let (mut write, mut read) = ws_stream.split();
let mut partial_msgs: HashMap<String, PartialMessage> = HashMap::new();
loop {
tokio::select! {
msg = read.next() => {
match msg {
Some(Ok(tungstenite::Message::Binary(data))) => {
match Frame::decode(data.as_ref()) {
Ok(frame) => {
let frame_type = frame.get_header("type").unwrap_or("").to_string();
if frame.method == FRAME_METHOD_CONTROL {
// Control frame: ping → respond with pong
if frame_type == "ping" {
let mut pong = frame.clone();
// Clear type header and set to pong
pong.set_header("type", "pong");
pong.payload = Vec::new();
let mut buf = Vec::new();
if pong.encode(&mut buf).is_ok() {
let _ = write.send(tungstenite::Message::Binary(buf.into())).await;
}
}
} else if frame.method == FRAME_METHOD_DATA && frame_type == "event" {
let start = Instant::now();
// Multi-part reassembly
let msg_id = frame.get_header("message_id").unwrap_or("").to_string();
let sum: i32 = frame.get_header("sum").and_then(|s| s.parse().ok()).unwrap_or(1);
let seq: i32 = frame.get_header("seq").and_then(|s| s.parse().ok()).unwrap_or(0);
let full_payload = if sum <= 1 {
Some(frame.payload.clone())
} else {
let entry = partial_msgs.entry(msg_id.clone()).or_insert_with(|| PartialMessage {
parts: HashMap::new(),
total: sum,
});
entry.parts.insert(seq, frame.payload.clone());
if entry.parts.len() as i32 >= entry.total {
// All parts received — reassemble in order
let mut combined = Vec::new();
for i in 0..entry.total {
if let Some(part) = entry.parts.get(&i) {
combined.extend_from_slice(part);
}
}
partial_msgs.remove(&msg_id);
Some(combined)
} else {
None // Still waiting for more parts
}
};
if let Some(payload_bytes) = full_payload {
// Process event
if let Ok(payload_str) = std::str::from_utf8(&payload_bytes) {
if let Ok(event) = serde_json::from_str::<serde_json::Value>(payload_str) {
handle_lark_event(&event, channel_id, &command_tx).await;
} else {
eprintln!("[Lark] event payload is not valid JSON");
}
}
// Send acknowledgment: echo frame back with {"code":200}
let elapsed_ms = start.elapsed().as_millis();
let mut ack = frame.clone();
ack.payload = br#"{"code":200}"#.to_vec();
ack.set_header("biz_rt", &elapsed_ms.to_string());
let mut buf = Vec::new();
if ack.encode(&mut buf).is_ok() {
let _ = write.send(tungstenite::Message::Binary(buf.into())).await;
}
}
}
}
Err(e) => {
eprintln!("[Lark] protobuf decode error: {e}, len={}", data.len());
}
}
}
Some(Ok(tungstenite::Message::Ping(data))) => {
let _ = write.send(tungstenite::Message::Pong(data)).await;
}
Some(Ok(tungstenite::Message::Close(_))) | None => {
eprintln!("[Lark] WebSocket closed, will reconnect");
break;
}
Some(Err(e)) => {
eprintln!("[Lark] WebSocket error: {e}");
break;
}
_ => {}
}
}
_ = shutdown_rx.changed() => {
let _ = write.close().await;
*status.lock().await = ChannelConnectionStatus::Disconnected;
return;
}
}
}
*status.lock().await = ChannelConnectionStatus::Connecting;
let delay = Duration::from_secs(3);
tokio::select! {
_ = tokio::time::sleep(delay) => {},
_ = shutdown_rx.changed() => break,
}
}
*status.lock().await = ChannelConnectionStatus::Disconnected;
});
Ok(())
}
}
async fn handle_lark_event(
event: &serde_json::Value,
channel_id: i32,
command_tx: &mpsc::Sender<IncomingCommand>,
) {
let event_type = event
.pointer("/header/event_type")
.and_then(|v| v.as_str())
.unwrap_or("");
if event_type == "im.message.receive_v1" {
let msg_type = event
.pointer("/event/message/message_type")
.and_then(|v| v.as_str())
.unwrap_or("");
if msg_type != "text" {
return;
}
let content_str = event
.pointer("/event/message/content")
.and_then(|v| v.as_str())
.unwrap_or("");
// Content is JSON string: {"text":"actual message"}
let text = serde_json::from_str::<serde_json::Value>(content_str)
.ok()
.and_then(|v| v.get("text").and_then(|t| t.as_str()).map(String::from))
.unwrap_or_default();
if text.is_empty() {
return;
}
let sender_id = event
.pointer("/event/sender/sender_id/open_id")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
eprintln!("[Lark] incoming message from {}: {}", sender_id, text);
let _ = command_tx
.send(IncomingCommand {
channel_id,
sender_id,
command_text: text,
metadata: event.clone(),
})
.await;
}
}
/// Fetch a fresh WebSocket endpoint URL from Feishu.
async fn fetch_ws_url(
client: &reqwest::Client,
app_id: &str,
app_secret: &str,
) -> Result<String, ChatChannelError> {
let resp = client
.post(format!("{}/callback/ws/endpoint", FEISHU_BASE_URL))
.json(&serde_json::json!({
"AppID": app_id,
"AppSecret": app_secret,
}))
.send()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
let ws_resp: WsConnectResponse = resp
.json()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
if ws_resp.code != 0 {
return Err(ChatChannelError::ConnectionFailed(format!(
"WS connect failed: code={}, msg={}",
ws_resp.code, ws_resp.msg
)));
}
ws_resp
.data
.and_then(|d| d.url)
.ok_or_else(|| ChatChannelError::ConnectionFailed("No WebSocket URL returned".into()))
}
#[async_trait]
impl ChatChannelBackend for LarkBackend {
fn channel_type(&self) -> ChannelType {
ChannelType::Lark
}
async fn start(
&self,
command_tx: mpsc::Sender<IncomingCommand>,
) -> Result<(), ChatChannelError> {
*self.status.lock().await = ChannelConnectionStatus::Connecting;
self.get_tenant_access_token().await?;
*self.status.lock().await = ChannelConnectionStatus::Connected;
if let Err(e) = self.start_ws_receiver(command_tx).await {
eprintln!("[Lark] WebSocket receiver failed to start: {e}");
}
Ok(())
}
async fn stop(&self) -> Result<(), ChatChannelError> {
if let Some(tx) = self.shutdown_tx.lock().await.take() {
let _ = tx.send(true);
}
*self.status.lock().await = ChannelConnectionStatus::Disconnected;
Ok(())
}
async fn status(&self) -> ChannelConnectionStatus {
*self.status.lock().await
}
async fn send_message(&self, text: &str) -> Result<SentMessageId, ChatChannelError> {
let content = serde_json::json!({ "text": text }).to_string();
self.send_lark_message("text", &content).await
}
async fn send_rich_message(
&self,
message: &RichMessage,
) -> Result<SentMessageId, ChatChannelError> {
let card = build_lark_card(message);
let content = serde_json::to_string(&card)
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
self.send_lark_message("interactive", &content).await
}
async fn test_connection(&self) -> Result<(), ChatChannelError> {
self.get_tenant_access_token().await?;
Ok(())
}
}
fn build_lark_card(msg: &RichMessage) -> serde_json::Value {
let header_color = match msg.level {
MessageLevel::Info => "blue",
MessageLevel::Warning => "orange",
MessageLevel::Error => "red",
};
let title = msg.title.as_deref().unwrap_or("Codeg");
let mut elements: Vec<serde_json::Value> = Vec::new();
if !msg.body.is_empty() {
elements.push(serde_json::json!({
"tag": "markdown",
"content": msg.body,
}));
}
if !msg.fields.is_empty() {
let field_elements: Vec<serde_json::Value> = msg
.fields
.iter()
.map(|(k, v)| {
serde_json::json!({
"is_short": true,
"text": {
"tag": "lark_md",
"content": format!("**{}**\n{}", k, v),
}
})
})
.collect();
elements.push(serde_json::json!({
"tag": "div",
"fields": field_elements,
}));
}
serde_json::json!({
"config": { "wide_screen_mode": true },
"header": {
"title": {
"tag": "plain_text",
"content": title,
},
"template": header_color,
},
"elements": elements,
})
}

View File

@@ -0,0 +1,2 @@
pub mod lark;
pub mod telegram;

View File

@@ -0,0 +1,255 @@
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::{mpsc, Mutex};
use crate::chat_channel::error::ChatChannelError;
use crate::chat_channel::traits::ChatChannelBackend;
use crate::chat_channel::types::*;
pub struct TelegramBackend {
bot_token: String,
chat_id: String,
client: reqwest::Client,
status: Arc<Mutex<ChannelConnectionStatus>>,
channel_id: i32,
shutdown_tx: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
}
impl TelegramBackend {
pub fn new(channel_id: i32, bot_token: String, chat_id: String) -> Self {
Self {
bot_token,
chat_id,
client: reqwest::Client::new(),
status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)),
channel_id,
shutdown_tx: Arc::new(Mutex::new(None)),
}
}
fn api_url(&self, method: &str) -> String {
format!(
"https://api.telegram.org/bot{}/{}",
self.bot_token, method
)
}
}
#[async_trait]
impl ChatChannelBackend for TelegramBackend {
fn channel_type(&self) -> ChannelType {
ChannelType::Telegram
}
async fn start(
&self,
command_tx: mpsc::Sender<IncomingCommand>,
) -> Result<(), ChatChannelError> {
*self.status.lock().await = ChannelConnectionStatus::Connecting;
// Verify bot token by calling getMe
let resp = self
.client
.get(&self.api_url("getMe"))
.send()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
if !resp.status().is_success() {
*self.status.lock().await = ChannelConnectionStatus::Error;
return Err(ChatChannelError::AuthenticationFailed(
"Invalid bot token".to_string(),
));
}
*self.status.lock().await = ChannelConnectionStatus::Connected;
// Start long-polling loop
let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
*self.shutdown_tx.lock().await = Some(shutdown_tx);
let client = self.client.clone();
let bot_token = self.bot_token.clone();
let channel_id = self.channel_id;
let status = self.status.clone();
tokio::spawn(async move {
let mut offset: i64 = 0;
loop {
if *shutdown_rx.borrow() {
break;
}
let url = format!(
"https://api.telegram.org/bot{}/getUpdates?timeout=30&offset={}",
bot_token, offset
);
let result = tokio::select! {
r = client.get(&url).send() => r,
_ = shutdown_rx.changed() => break,
};
match result {
Ok(resp) => {
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(updates) = body.get("result").and_then(|r| r.as_array()) {
for update in updates {
if let Some(uid) =
update.get("update_id").and_then(|u| u.as_i64())
{
offset = uid + 1;
}
if let Some(text) = update
.pointer("/message/text")
.and_then(|t| t.as_str())
{
let sender_id = update
.pointer("/message/from/id")
.and_then(|i| i.as_i64())
.map(|i| i.to_string())
.unwrap_or_default();
let _ = command_tx
.send(IncomingCommand {
channel_id,
sender_id,
command_text: text.to_string(),
metadata: update.clone(),
})
.await;
}
}
}
}
}
Err(e) => {
eprintln!("[Telegram] polling error: {e}");
*status.lock().await = ChannelConnectionStatus::Error;
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
*status.lock().await = ChannelConnectionStatus::Connected;
}
}
}
*status.lock().await = ChannelConnectionStatus::Disconnected;
});
Ok(())
}
async fn stop(&self) -> Result<(), ChatChannelError> {
if let Some(tx) = self.shutdown_tx.lock().await.take() {
let _ = tx.send(true);
}
*self.status.lock().await = ChannelConnectionStatus::Disconnected;
Ok(())
}
async fn status(&self) -> ChannelConnectionStatus {
*self.status.lock().await
}
async fn send_message(&self, text: &str) -> Result<SentMessageId, ChatChannelError> {
let body = serde_json::json!({
"chat_id": self.chat_id,
"text": text,
"parse_mode": "Markdown",
});
let resp = self
.client
.post(&self.api_url("sendMessage"))
.json(&body)
.send()
.await
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
let result: serde_json::Value = resp
.json()
.await
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
let message_id = result
.pointer("/result/message_id")
.and_then(|v| v.as_i64())
.map(|id| id.to_string())
.unwrap_or_default();
Ok(SentMessageId(message_id))
}
async fn send_rich_message(
&self,
message: &RichMessage,
) -> Result<SentMessageId, ChatChannelError> {
let text = format_telegram_markdown(message);
self.send_message(&text).await
}
async fn test_connection(&self) -> Result<(), ChatChannelError> {
let resp = self
.client
.get(&self.api_url("getMe"))
.send()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?;
if resp.status().is_success() {
Ok(())
} else {
Err(ChatChannelError::AuthenticationFailed(
"Invalid bot token".to_string(),
))
}
}
}
fn format_telegram_markdown(msg: &RichMessage) -> String {
let mut text = String::new();
let level_emoji = match msg.level {
MessageLevel::Info => "",
MessageLevel::Warning => "⚠️",
MessageLevel::Error => "",
};
if let Some(title) = &msg.title {
text.push_str(&format!("{} *{}*\n", level_emoji, escape_markdown(title)));
}
text.push_str(&escape_markdown(&msg.body));
if !msg.fields.is_empty() {
text.push('\n');
for (key, value) in &msg.fields {
text.push_str(&format!(
"\n*{}*: {}",
escape_markdown(key),
escape_markdown(value)
));
}
}
text
}
fn escape_markdown(text: &str) -> String {
text.replace('_', "\\_")
.replace('*', "\\*")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('(', "\\(")
.replace(')', "\\)")
.replace('~', "\\~")
.replace('`', "\\`")
.replace('>', "\\>")
.replace('#', "\\#")
.replace('+', "\\+")
.replace('-', "\\-")
.replace('=', "\\=")
.replace('|', "\\|")
.replace('{', "\\{")
.replace('}', "\\}")
.replace('.', "\\.")
.replace('!', "\\!")
}

View File

@@ -0,0 +1,97 @@
use sea_orm::DatabaseConnection;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use super::command_handlers;
use super::manager::ChatChannelManager;
use super::types::IncomingCommand;
use crate::db::service::chat_channel_message_log_service;
pub fn spawn_command_dispatcher(
mut command_rx: mpsc::Receiver<IncomingCommand>,
manager: ChatChannelManager,
db_conn: DatabaseConnection,
) -> JoinHandle<()> {
tokio::spawn(async move {
while let Some(cmd) = command_rx.recv().await {
let text = cmd.command_text.trim();
// Log inbound command
let _ = chat_channel_message_log_service::create_log(
&db_conn,
cmd.channel_id,
"inbound",
"command_query",
text,
"sent",
None,
)
.await;
let response = dispatch_command(text, &db_conn, &manager).await;
// Send response back via the same channel
let send_result = manager.send_to_channel(cmd.channel_id, &response).await;
let (status, error_detail) = match &send_result {
Ok(_) => ("sent", None),
Err(e) => ("failed", Some(e.to_string())),
};
let _ = chat_channel_message_log_service::create_log(
&db_conn,
cmd.channel_id,
"outbound",
"command_response",
&response.to_plain_text(),
status,
error_detail,
)
.await;
}
})
}
async fn dispatch_command(
text: &str,
db: &DatabaseConnection,
manager: &ChatChannelManager,
) -> super::types::RichMessage {
let parts: Vec<&str> = text.splitn(2, ' ').collect();
let command = parts[0].to_lowercase();
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
match command.as_str() {
"/recent" => command_handlers::handle_recent(db).await,
"/search" => {
if args.is_empty() {
super::types::RichMessage::info("用法: /search <关键词>")
.with_title("参数错误")
} else {
command_handlers::handle_search(db, args).await
}
}
"/detail" => {
if let Ok(id) = args.parse::<i32>() {
command_handlers::handle_detail(db, id).await
} else {
super::types::RichMessage::info("用法: /detail <会话ID>")
.with_title("参数错误")
}
}
"/today" => command_handlers::handle_today(db).await,
"/status" => command_handlers::handle_status(manager).await,
"/help" | "/start" => command_handlers::handle_help(),
_ => {
if text.starts_with('/') {
super::types::RichMessage::info(format!(
"未知命令: {}\n输入 /help 查看可用命令",
command
))
.with_title("未知命令")
} else {
// Non-command messages are ignored
return command_handlers::handle_help();
}
}
}
}

View File

@@ -0,0 +1,218 @@
use chrono::Utc;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};
use super::manager::ChatChannelManager;
use super::types::{MessageLevel, RichMessage};
use crate::db::entities::conversation;
pub async fn handle_recent(db: &DatabaseConnection) -> RichMessage {
let rows = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.order_by_desc(conversation::Column::CreatedAt)
.all(db)
.await
{
Ok(rows) => rows,
Err(e) => {
return RichMessage {
title: Some("查询失败".to_string()),
body: e.to_string(),
fields: Vec::new(),
level: MessageLevel::Error,
};
}
};
let recent: Vec<_> = rows.into_iter().take(5).collect();
if recent.is_empty() {
return RichMessage::info("暂无会话记录").with_title("最近会话");
}
let mut body = String::new();
for (i, conv) in recent.iter().enumerate() {
let title = conv.title.as_deref().unwrap_or("(无标题)");
let agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!(
"{}. [{}] {} ({})\n",
i + 1,
agent,
title,
time
));
}
RichMessage::info(body.trim_end()).with_title("最近 5 条会话")
}
pub async fn handle_search(db: &DatabaseConnection, keyword: &str) -> RichMessage {
let rows = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.order_by_desc(conversation::Column::CreatedAt)
.all(db)
.await
{
Ok(rows) => rows,
Err(e) => {
return RichMessage {
title: Some("查询失败".to_string()),
body: e.to_string(),
fields: Vec::new(),
level: MessageLevel::Error,
};
}
};
let keyword_lower = keyword.to_lowercase();
let matched: Vec<_> = rows
.into_iter()
.filter(|c| {
c.title
.as_deref()
.map(|t| t.to_lowercase().contains(&keyword_lower))
.unwrap_or(false)
})
.take(10)
.collect();
if matched.is_empty() {
return RichMessage::info(format!("未找到包含 \"{keyword}\" 的会话"))
.with_title("搜索结果");
}
let mut body = String::new();
for (i, conv) in matched.iter().enumerate() {
let title = conv.title.as_deref().unwrap_or("(无标题)");
let agent = &conv.agent_type;
body.push_str(&format!("{}. [{}] {} (ID:{})\n", i + 1, agent, title, conv.id));
}
RichMessage::info(body.trim_end())
.with_title(&format!("搜索 \"{}\" - {} 条结果", keyword, matched.len()))
}
pub async fn handle_detail(db: &DatabaseConnection, conversation_id: i32) -> RichMessage {
let conv = match conversation::Entity::find_by_id(conversation_id)
.filter(conversation::Column::DeletedAt.is_null())
.one(db)
.await
{
Ok(Some(c)) => c,
Ok(None) => {
return RichMessage::info(format!("会话 {conversation_id} 不存在"))
.with_title("未找到");
}
Err(e) => {
return RichMessage {
title: Some("查询失败".to_string()),
body: e.to_string(),
fields: Vec::new(),
level: MessageLevel::Error,
};
}
};
let title = conv.title.as_deref().unwrap_or("(无标题)");
RichMessage::info(title)
.with_title(&format!("会话详情 #{}", conv.id))
.with_field("代理", &conv.agent_type)
.with_field("状态", format!("{:?}", conv.status))
.with_field("消息数", &conv.message_count.to_string())
.with_field("创建时间", &conv.created_at.format("%Y-%m-%d %H:%M").to_string())
}
pub async fn handle_today(db: &DatabaseConnection) -> RichMessage {
let now = Utc::now();
let today_start = now
.date_naive()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc();
let rows = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.filter(conversation::Column::CreatedAt.gte(today_start))
.order_by_desc(conversation::Column::CreatedAt)
.all(db)
.await
{
Ok(rows) => rows,
Err(e) => {
return RichMessage {
title: Some("查询失败".to_string()),
body: e.to_string(),
fields: Vec::new(),
level: MessageLevel::Error,
};
}
};
if rows.is_empty() {
return RichMessage::info("今日暂无编码活动").with_title("今日活动");
}
// Group by agent_type
let mut by_agent: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
let mut titles: Vec<String> = Vec::new();
for conv in &rows {
*by_agent.entry(conv.agent_type.clone()).or_insert(0) += 1;
if let Some(t) = &conv.title {
if titles.len() < 5 {
titles.push(t.clone());
}
}
}
let mut body = format!("会话总数: {}", rows.len());
body.push_str("\n\n按代理:");
for (agent, count) in &by_agent {
body.push_str(&format!("\n {agent} - {count}"));
}
if !titles.is_empty() {
body.push_str("\n\n最近活动:");
for t in &titles {
body.push_str(&format!("\n{t}"));
}
}
RichMessage::info(body).with_title(&format!(
"今日活动 ({})",
now.format("%Y-%m-%d")
))
}
pub async fn handle_status(manager: &ChatChannelManager) -> RichMessage {
let statuses = manager.get_status().await;
if statuses.is_empty() {
return RichMessage::info("暂无活跃渠道").with_title("渠道状态");
}
let mut body = String::new();
for s in &statuses {
let icon = match s.status.as_str() {
"connected" => "",
"connecting" => "",
"error" => "",
_ => "",
};
body.push_str(&format!(
"{} {} [{}] - {}\n",
icon, s.name, s.channel_type, s.status
));
}
RichMessage::info(body.trim_end()).with_title("渠道状态")
}
pub fn handle_help() -> RichMessage {
RichMessage::info(
"/recent - 最近 5 条会话\n\
/search <关键词> - 搜索会话\n\
/detail <ID> - 会话详情\n\
/today - 今日活动汇总\n\
/status - 渠道连接状态\n\
/help - 显示帮助",
)
.with_title("Codeg Bot 帮助")
}

View File

@@ -0,0 +1,39 @@
use crate::app_error::AppCommandError;
#[derive(Debug, thiserror::Error)]
pub enum ChatChannelError {
#[error("connection failed: {0}")]
ConnectionFailed(String),
#[error("send failed: {0}")]
SendFailed(String),
#[error("authentication failed: {0}")]
AuthenticationFailed(String),
#[error("configuration invalid: {0}")]
ConfigurationInvalid(String),
#[error("not connected")]
NotConnected,
#[error("already connected")]
AlreadyConnected,
#[error("channel not found: {0}")]
NotFound(i32),
#[error("{0}")]
Other(String),
}
impl From<ChatChannelError> for AppCommandError {
fn from(err: ChatChannelError) -> Self {
match &err {
ChatChannelError::NotFound(_) => AppCommandError::not_found(err.to_string()),
ChatChannelError::AuthenticationFailed(_) => {
AppCommandError::authentication_failed(err.to_string())
}
ChatChannelError::ConfigurationInvalid(_) => {
AppCommandError::configuration_invalid(err.to_string())
}
ChatChannelError::ConnectionFailed(_) | ChatChannelError::SendFailed(_) => {
AppCommandError::network(err.to_string())
}
_ => AppCommandError::task_execution_failed(err.to_string()),
}
}
}

View File

@@ -0,0 +1,217 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use sea_orm::DatabaseConnection;
use tokio::task::JoinHandle;
use super::manager::ChatChannelManager;
use super::message_formatter;
use super::types::RichMessage;
use crate::db::service::{chat_channel_message_log_service, chat_channel_service};
use crate::web::event_bridge::WebEventBroadcaster;
/// Minimum interval between pushes for the same event type per channel (debounce).
const DEBOUNCE_SECS: u64 = 5;
pub fn spawn_event_subscriber(
broadcaster: Arc<WebEventBroadcaster>,
manager: ChatChannelManager,
db_conn: DatabaseConnection,
) -> JoinHandle<()> {
tokio::spawn(async move {
let mut rx = broadcaster.subscribe();
let mut last_push: HashMap<(i32, String), Instant> = HashMap::new();
loop {
let event = match rx.recv().await {
Ok(e) => e,
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
eprintln!("[ChatChannel] event subscriber lagged by {n} messages");
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
eprintln!("[ChatChannel] event broadcaster closed, stopping subscriber");
break;
}
};
let message = match parse_event(&event.channel, &event.payload) {
Some((event_type, msg)) => {
// Check enabled channels and forward
let channels = match chat_channel_service::list_enabled(&db_conn).await {
Ok(c) => c,
Err(e) => {
eprintln!("[ChatChannel] failed to list channels: {e}");
continue;
}
};
for ch in &channels {
// Check event filter
if let Some(filter_json) = &ch.event_filter_json {
if let Ok(filter) =
serde_json::from_str::<Vec<String>>(filter_json)
{
if !filter.contains(&event_type) {
continue;
}
}
}
// Debounce
let key = (ch.id, event_type.clone());
let now = Instant::now();
if let Some(last) = last_push.get(&key) {
if now.duration_since(*last) < Duration::from_secs(DEBOUNCE_SECS) {
continue;
}
}
last_push.insert(key, now);
// Send
let send_result = manager.send_to_channel(ch.id, &msg).await;
let (status, error_detail) = match &send_result {
Ok(_) => ("sent", None),
Err(e) => ("failed", Some(e.to_string())),
};
let _ = chat_channel_message_log_service::create_log(
&db_conn,
ch.id,
"outbound",
"event_push",
&msg.to_plain_text(),
status,
error_detail,
)
.await;
}
Some(msg)
}
None => None,
};
drop(message);
}
})
}
fn parse_event(channel: &str, payload: &serde_json::Value) -> Option<(String, RichMessage)> {
match channel {
"acp://event" => parse_acp_event(payload),
"folder://git-push-succeeded" => parse_git_push(payload),
"folder://git-commit-succeeded" => parse_git_commit(payload),
_ => None,
}
}
fn parse_acp_event(payload: &serde_json::Value) -> Option<(String, RichMessage)> {
let event_type = payload.get("type")?.as_str()?;
let connection_id = payload
.get("connection_id")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
match event_type {
"session_started" => {
let agent_type = payload
.pointer("/data/agent_type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown Agent");
let folder = payload
.pointer("/data/folder_name")
.and_then(|v| v.as_str())
.unwrap_or(connection_id);
Some((
"session_started".to_string(),
message_formatter::format_session_started(agent_type, folder),
))
}
"turn_complete" => {
let stop_reason = payload
.pointer("/data/stop_reason")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
// Only push for end_turn, not for intermediate completions
if stop_reason != "end_turn" {
return None;
}
let agent_type = payload
.pointer("/data/agent_type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown Agent");
Some((
"turn_complete".to_string(),
message_formatter::format_turn_complete(agent_type, stop_reason),
))
}
"error" => {
let agent_type = payload
.pointer("/data/agent_type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown Agent");
let message = payload
.pointer("/data/message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
Some((
"error".to_string(),
message_formatter::format_agent_error(agent_type, message),
))
}
"status_changed" => {
let status = payload
.pointer("/data/status")
.and_then(|v| v.as_str())?;
if status != "disconnected" {
return None;
}
let agent_type = payload
.pointer("/data/agent_type")
.and_then(|v| v.as_str())
.unwrap_or("Unknown Agent");
Some((
"status_disconnected".to_string(),
message_formatter::format_agent_disconnected(agent_type),
))
}
// Phase 2: "permission_request" will be handled here
_ => None,
}
}
fn parse_git_push(payload: &serde_json::Value) -> Option<(String, RichMessage)> {
let folder_name = payload
.get("folder_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let branch = payload
.get("branch")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let commits = payload
.get("pushed_commits")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
Some((
"git_push".to_string(),
message_formatter::format_git_push(folder_name, branch, commits),
))
}
fn parse_git_commit(payload: &serde_json::Value) -> Option<(String, RichMessage)> {
let folder_name = payload
.get("folder_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let files = payload
.get("committed_files")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
Some((
"git_commit".to_string(),
message_formatter::format_git_commit(folder_name, files),
))
}

View File

@@ -0,0 +1,255 @@
use std::collections::HashMap;
use std::sync::Arc;
use sea_orm::DatabaseConnection;
use tokio::sync::{mpsc, Mutex};
use super::error::ChatChannelError;
use super::traits::ChatChannelBackend;
use super::types::*;
use crate::web::event_bridge::WebEventBroadcaster;
struct ActiveChannel {
id: i32,
name: String,
channel_type: ChannelType,
backend: Box<dyn ChatChannelBackend>,
}
/// Inner state shared across clones.
struct Inner {
channels: Mutex<HashMap<i32, ActiveChannel>>,
command_tx: mpsc::Sender<IncomingCommand>,
command_rx: Mutex<Option<mpsc::Receiver<IncomingCommand>>>,
}
pub struct ChatChannelManager {
inner: Arc<Inner>,
}
impl ChatChannelManager {
pub fn new() -> Self {
let (command_tx, command_rx) = mpsc::channel(256);
Self {
inner: Arc::new(Inner {
channels: Mutex::new(HashMap::new()),
command_tx,
command_rx: Mutex::new(Some(command_rx)),
}),
}
}
/// Shallow clone sharing the same state (like ConnectionManager::clone_ref).
pub fn clone_ref(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
pub fn command_sender(&self) -> mpsc::Sender<IncomingCommand> {
self.inner.command_tx.clone()
}
/// Take the command receiver (can only be called once, at startup).
pub async fn take_command_receiver(&self) -> Option<mpsc::Receiver<IncomingCommand>> {
self.inner.command_rx.lock().await.take()
}
pub async fn add_channel(
&self,
id: i32,
name: String,
channel_type: ChannelType,
backend: Box<dyn ChatChannelBackend>,
) -> Result<(), ChatChannelError> {
let command_tx = self.inner.command_tx.clone();
backend.start(command_tx).await?;
let channel = ActiveChannel {
id,
name,
channel_type,
backend,
};
self.inner.channels.lock().await.insert(id, channel);
Ok(())
}
pub async fn remove_channel(&self, id: i32) -> Result<(), ChatChannelError> {
let mut channels = self.inner.channels.lock().await;
if let Some(channel) = channels.remove(&id) {
channel.backend.stop().await?;
}
Ok(())
}
pub async fn stop_all(&self) {
let mut channels = self.inner.channels.lock().await;
for (_, channel) in channels.drain() {
let _ = channel.backend.stop().await;
}
}
pub async fn send_to_channel(
&self,
channel_id: i32,
message: &RichMessage,
) -> Result<SentMessageId, ChatChannelError> {
let channels = self.inner.channels.lock().await;
let channel = channels
.get(&channel_id)
.ok_or(ChatChannelError::NotFound(channel_id))?;
channel.backend.send_rich_message(message).await
}
pub async fn send_to_all(&self, message: &RichMessage) {
let channels = self.inner.channels.lock().await;
for (_, channel) in channels.iter() {
let _ = channel.backend.send_rich_message(message).await;
}
}
pub async fn get_status(&self) -> Vec<crate::models::ChannelStatusInfo> {
let channels = self.inner.channels.lock().await;
let mut result = Vec::new();
for (_, ch) in channels.iter() {
let status = ch.backend.status().await;
result.push(crate::models::ChannelStatusInfo {
channel_id: ch.id,
name: ch.name.clone(),
channel_type: ch.channel_type.to_string(),
status: serde_json::to_value(status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| "unknown".to_string()),
});
}
result
}
pub async fn test_channel(&self, id: i32) -> Result<(), ChatChannelError> {
let channels = self.inner.channels.lock().await;
let channel = channels
.get(&id)
.ok_or(ChatChannelError::NotFound(id))?;
channel.backend.test_connection().await
}
pub async fn is_connected(&self, id: i32) -> bool {
let channels = self.inner.channels.lock().await;
if let Some(ch) = channels.get(&id) {
ch.backend.status().await == ChannelConnectionStatus::Connected
} else {
false
}
}
/// Start background tasks (event subscriber + command dispatcher) and
/// auto-connect all enabled channels from DB.
pub async fn start_background(
&self,
broadcaster: Arc<WebEventBroadcaster>,
db_conn: DatabaseConnection,
) {
let db_conn2 = db_conn.clone();
// Spawn event subscriber
let manager_for_events = self.clone_ref();
super::event_subscriber::spawn_event_subscriber(
broadcaster,
manager_for_events,
db_conn.clone(),
);
// Spawn command dispatcher
if let Some(command_rx) = self.take_command_receiver().await {
let manager_for_cmds = self.clone_ref();
super::command_dispatcher::spawn_command_dispatcher(
command_rx,
manager_for_cmds,
db_conn.clone(),
);
}
// Spawn daily report scheduler
let manager_for_scheduler = self.clone_ref();
super::scheduler::spawn_daily_report_scheduler(manager_for_scheduler, db_conn.clone());
// Auto-connect enabled channels
self.auto_connect_channels(&db_conn2).await;
}
async fn auto_connect_channels(&self, db_conn: &DatabaseConnection) {
let channels =
match crate::db::service::chat_channel_service::list_enabled(db_conn).await {
Ok(c) => c,
Err(e) => {
eprintln!("[ChatChannel] failed to load enabled channels: {e}");
return;
}
};
for ch in channels {
let channel_type: ChannelType = match serde_json::from_value(
serde_json::Value::String(ch.channel_type.clone()),
) {
Ok(t) => t,
Err(_) => continue,
};
let config: serde_json::Value = match serde_json::from_str(&ch.config_json) {
Ok(v) => v,
Err(_) => continue,
};
let token = match crate::keyring_store::get_channel_token(ch.id) {
Some(t) => t,
None => continue,
};
let backend: Box<dyn ChatChannelBackend> = match channel_type {
ChannelType::Telegram => {
let chat_id = config
.get("chat_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if chat_id.is_empty() {
continue;
}
Box::new(super::backends::telegram::TelegramBackend::new(
ch.id, token, chat_id,
))
}
ChannelType::Lark => {
let app_id = config
.get("app_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let chat_id = config
.get("chat_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if app_id.is_empty() || chat_id.is_empty() {
continue;
}
Box::new(super::backends::lark::LarkBackend::new(
ch.id, app_id, token, chat_id,
))
}
};
if let Err(e) = self.add_channel(ch.id, ch.name.clone(), channel_type, backend).await {
eprintln!(
"[ChatChannel] failed to auto-connect '{}' (id={}): {e}",
ch.name, ch.id
);
} else {
eprintln!("[ChatChannel] auto-connected '{}' (id={})", ch.name, ch.id);
}
}
}
}

View File

@@ -0,0 +1,86 @@
use super::types::{MessageLevel, RichMessage};
pub fn format_session_started(agent_type: &str, folder_name: &str) -> RichMessage {
RichMessage::info(format!("{agent_type}{folder_name} 开始了新会话"))
.with_title("新会话")
}
pub fn format_turn_complete(agent_type: &str, stop_reason: &str) -> RichMessage {
let reason = match stop_reason {
"end_turn" => "正常结束",
"cancelled" => "已取消",
_ => stop_reason,
};
RichMessage::info(format!("{agent_type} 会话已完成"))
.with_title("会话完成")
.with_field("结束原因", reason)
}
pub fn format_agent_error(agent_type: &str, message: &str) -> RichMessage {
RichMessage {
title: Some("代理错误".to_string()),
body: format!("{agent_type} 发生错误"),
fields: vec![("错误信息".to_string(), message.to_string())],
level: MessageLevel::Error,
}
}
pub fn format_agent_disconnected(agent_type: &str) -> RichMessage {
RichMessage {
title: Some("代理断开".to_string()),
body: format!("{agent_type} 已断开连接"),
fields: Vec::new(),
level: MessageLevel::Warning,
}
}
pub fn format_git_push(folder_name: &str, branch: &str, commits: u32) -> RichMessage {
RichMessage::info(format!(
"Git Push 成功: {commits} 个提交推送到 {branch}"
))
.with_title("Git Push")
.with_field("项目", folder_name)
}
pub fn format_git_commit(folder_name: &str, files: u32) -> RichMessage {
RichMessage::info(format!("Git Commit: {files} 个文件已提交"))
.with_title("Git Commit")
.with_field("项目", folder_name)
}
pub struct DailyReportData {
pub date: String,
pub conversations_by_agent: Vec<(String, u32)>,
pub total_conversations: u32,
pub projects_involved: Vec<String>,
pub key_activities: Vec<String>,
}
pub fn format_daily_report(report: &DailyReportData) -> RichMessage {
let mut body = format!("今日编码活动汇总 ({})", report.date);
body.push_str(&format!("\n\n会话总数: {}", report.total_conversations));
if !report.conversations_by_agent.is_empty() {
body.push_str("\n\n按代理分布:");
for (agent, count) in &report.conversations_by_agent {
body.push_str(&format!("\n {} - {} 个会话", agent, count));
}
}
if !report.projects_involved.is_empty() {
body.push_str(&format!(
"\n\n涉及项目: {}",
report.projects_involved.join(", ")
));
}
if !report.key_activities.is_empty() {
body.push_str("\n\n主要活动:");
for activity in &report.key_activities {
body.push_str(&format!("\n{}", activity));
}
}
RichMessage::info(body).with_title("每日编码报告")
}

View File

@@ -0,0 +1,10 @@
pub mod backends;
pub mod command_dispatcher;
pub mod command_handlers;
pub mod error;
pub mod event_subscriber;
pub mod manager;
pub mod message_formatter;
pub mod scheduler;
pub mod traits;
pub mod types;

View File

@@ -0,0 +1,130 @@
use std::collections::HashSet;
use chrono::{Local, NaiveDate, Timelike, Utc};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder};
use tokio::task::JoinHandle;
use super::manager::ChatChannelManager;
use super::message_formatter::{self, DailyReportData};
use crate::db::entities::conversation;
use crate::db::service::{chat_channel_message_log_service, chat_channel_service};
pub fn spawn_daily_report_scheduler(
manager: ChatChannelManager,
db_conn: DatabaseConnection,
) -> JoinHandle<()> {
tokio::spawn(async move {
let mut sent_today: HashSet<(i32, NaiveDate)> = HashSet::new();
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
let now = Local::now();
let today = now.date_naive();
let current_time = format!("{:02}:{:02}", now.hour(), now.minute());
// Clean up old entries from sent_today
sent_today.retain(|(_, date)| *date == today);
let channels = match chat_channel_service::list_enabled(&db_conn).await {
Ok(c) => c,
Err(e) => {
eprintln!("[ChatChannel] scheduler: failed to list channels: {e}");
continue;
}
};
for ch in &channels {
if !ch.daily_report_enabled {
continue;
}
let report_time = ch
.daily_report_time
.as_deref()
.unwrap_or("18:00");
if current_time != report_time {
continue;
}
let key = (ch.id, today);
if sent_today.contains(&key) {
continue;
}
// Generate and send report
let report = generate_daily_report(&db_conn).await;
let message = message_formatter::format_daily_report(&report);
let send_result = manager.send_to_channel(ch.id, &message).await;
let (status, error_detail) = match &send_result {
Ok(_) => ("sent", None),
Err(e) => ("failed", Some(e.to_string())),
};
let _ = chat_channel_message_log_service::create_log(
&db_conn,
ch.id,
"outbound",
"daily_report",
&message.to_plain_text(),
status,
error_detail,
)
.await;
sent_today.insert(key);
}
}
})
}
async fn generate_daily_report(db: &DatabaseConnection) -> DailyReportData {
let now = Utc::now();
let today_start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
let rows = conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.filter(conversation::Column::CreatedAt.gte(today_start))
.order_by_desc(conversation::Column::CreatedAt)
.all(db)
.await
.unwrap_or_default();
let mut by_agent: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
let mut folder_ids: HashSet<i32> = HashSet::new();
let mut activities: Vec<String> = Vec::new();
for conv in &rows {
*by_agent.entry(conv.agent_type.clone()).or_insert(0) += 1;
folder_ids.insert(conv.folder_id);
if let Some(title) = &conv.title {
if activities.len() < 10 {
activities.push(title.clone());
}
}
}
// Resolve folder names
let mut project_names: Vec<String> = Vec::new();
for fid in &folder_ids {
if let Ok(Some(folder)) = crate::db::entities::folder::Entity::find_by_id(*fid)
.one(db)
.await
{
project_names.push(folder.name);
}
}
let conversations_by_agent: Vec<(String, u32)> = by_agent.into_iter().collect();
DailyReportData {
date: now.format("%Y-%m-%d").to_string(),
total_conversations: rows.len() as u32,
conversations_by_agent,
projects_involved: project_names,
key_activities: activities,
}
}

View File

@@ -0,0 +1,53 @@
use async_trait::async_trait;
use tokio::sync::mpsc;
use super::error::ChatChannelError;
use super::types::*;
#[async_trait]
pub trait ChatChannelBackend: Send + Sync + 'static {
fn channel_type(&self) -> ChannelType;
/// Start the receiving loop. `command_tx` forwards incoming IM messages
/// to the central command dispatcher.
async fn start(
&self,
command_tx: mpsc::Sender<IncomingCommand>,
) -> Result<(), ChatChannelError>;
/// Stop the backend connection gracefully.
async fn stop(&self) -> Result<(), ChatChannelError>;
/// Current connection status.
async fn status(&self) -> ChannelConnectionStatus;
/// Send a plain text message.
async fn send_message(&self, text: &str) -> Result<SentMessageId, ChatChannelError>;
/// Send a rich/structured message (Telegram Markdown / Lark Card).
async fn send_rich_message(
&self,
message: &RichMessage,
) -> Result<SentMessageId, ChatChannelError>;
/// [Phase 2] Send an interactive message with action buttons.
/// Default implementation degrades to send_rich_message.
async fn send_interactive_message(
&self,
message: &InteractiveMessage,
) -> Result<SentMessageId, ChatChannelError> {
self.send_rich_message(&message.to_rich_fallback()).await
}
/// [Phase 2] Update an already-sent message (e.g., permission status change).
async fn update_message(
&self,
_message_id: &SentMessageId,
_message: &RichMessage,
) -> Result<(), ChatChannelError> {
Ok(())
}
/// Test the connection (used by "Test Connection" button in UI).
async fn test_connection(&self) -> Result<(), ChatChannelError>;
}

View File

@@ -0,0 +1,125 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelType {
Lark,
Telegram,
}
impl std::fmt::Display for ChannelType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChannelType::Lark => write!(f, "lark"),
ChannelType::Telegram => write!(f, "telegram"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelConnectionStatus {
Connected,
Connecting,
Disconnected,
Error,
}
#[derive(Debug, Clone)]
pub struct SentMessageId(pub String);
pub struct IncomingCommand {
pub channel_id: i32,
pub sender_id: String,
pub command_text: String,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageLevel {
Info,
Warning,
Error,
}
#[derive(Debug, Clone)]
pub struct RichMessage {
pub title: Option<String>,
pub body: String,
pub fields: Vec<(String, String)>,
pub level: MessageLevel,
}
impl RichMessage {
pub fn info(body: impl Into<String>) -> Self {
Self {
title: None,
body: body.into(),
fields: Vec::new(),
level: MessageLevel::Info,
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.fields.push((key.into(), value.into()));
self
}
pub fn to_plain_text(&self) -> String {
let mut text = String::new();
if let Some(title) = &self.title {
text.push_str(title);
text.push('\n');
}
text.push_str(&self.body);
for (key, value) in &self.fields {
text.push_str(&format!("\n{}: {}", key, value));
}
text
}
}
// ── Phase 2 forward-compatible types ──
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ButtonStyle {
Primary,
Danger,
Default,
}
#[derive(Debug, Clone)]
pub struct MessageButton {
pub id: String,
pub label: String,
pub style: ButtonStyle,
}
#[derive(Debug, Clone)]
pub struct InteractiveMessage {
pub base: RichMessage,
pub buttons: Vec<MessageButton>,
pub callback_context: serde_json::Value,
}
impl InteractiveMessage {
pub fn to_rich_fallback(&self) -> RichMessage {
let mut msg = self.base.clone();
if !self.buttons.is_empty() {
let button_text: Vec<String> = self
.buttons
.iter()
.map(|b| format!("[{}]", b.label))
.collect();
msg.body.push_str(&format!("\n\n{}", button_text.join(" ")));
}
msg
}
}

View File

@@ -0,0 +1,380 @@
use crate::app_error::AppCommandError;
use crate::chat_channel::backends::lark::LarkBackend;
use crate::chat_channel::backends::telegram::TelegramBackend;
use crate::chat_channel::manager::ChatChannelManager;
use crate::chat_channel::traits::ChatChannelBackend;
use crate::chat_channel::types::ChannelType;
use crate::db::service::{chat_channel_message_log_service, chat_channel_service};
use crate::db::AppDatabase;
use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
// ---------------------------------------------------------------------------
// Shared core functions (used by both Tauri commands and web handlers)
// ---------------------------------------------------------------------------
pub async fn list_chat_channels_core(
db: &AppDatabase,
) -> Result<Vec<ChatChannelInfo>, AppCommandError> {
let rows = chat_channel_service::list_all(&db.conn)
.await
.map_err(AppCommandError::from)?;
Ok(rows.into_iter().map(ChatChannelInfo::from).collect())
}
pub async fn create_chat_channel_core(
db: &AppDatabase,
name: String,
channel_type: String,
config_json: String,
enabled: bool,
daily_report_enabled: bool,
daily_report_time: Option<String>,
) -> Result<ChatChannelInfo, AppCommandError> {
// Validate channel_type
let _: ChannelType = serde_json::from_value(serde_json::Value::String(channel_type.clone()))
.map_err(|_| {
AppCommandError::invalid_input(format!("Invalid channel type: {channel_type}"))
})?;
let model = chat_channel_service::create(
&db.conn,
name,
channel_type,
config_json,
enabled,
daily_report_enabled,
daily_report_time,
)
.await
.map_err(AppCommandError::from)?;
Ok(ChatChannelInfo::from(model))
}
pub async fn update_chat_channel_core(
db: &AppDatabase,
id: i32,
name: Option<String>,
enabled: Option<bool>,
config_json: Option<String>,
event_filter_json: Option<Option<String>>,
daily_report_enabled: Option<bool>,
daily_report_time: Option<Option<String>>,
) -> Result<ChatChannelInfo, AppCommandError> {
let model = chat_channel_service::update(
&db.conn,
id,
name,
enabled,
config_json,
event_filter_json,
daily_report_enabled,
daily_report_time,
)
.await
.map_err(AppCommandError::from)?;
Ok(ChatChannelInfo::from(model))
}
pub async fn delete_chat_channel_core(db: &AppDatabase, id: i32) -> Result<(), AppCommandError> {
chat_channel_service::delete(&db.conn, id)
.await
.map_err(AppCommandError::from)?;
let _ = crate::keyring_store::delete_channel_token(id);
Ok(())
}
pub async fn connect_chat_channel_core(
db: &AppDatabase,
manager: &ChatChannelManager,
id: i32,
) -> Result<(), AppCommandError> {
let model = chat_channel_service::get_by_id(&db.conn, id)
.await
.map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?;
let channel_type: ChannelType =
serde_json::from_value(serde_json::Value::String(model.channel_type.clone()))
.map_err(|_| {
AppCommandError::configuration_invalid(format!(
"Invalid channel type: {}",
model.channel_type
))
})?;
let backend: Box<dyn crate::chat_channel::traits::ChatChannelBackend> = match channel_type {
ChannelType::Telegram => {
let config: serde_json::Value =
serde_json::from_str(&model.config_json).map_err(|e| {
AppCommandError::configuration_invalid("Invalid config JSON")
.with_detail(e.to_string())
})?;
let chat_id = config
.get("chat_id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppCommandError::configuration_missing("chat_id is required"))?
.to_string();
let bot_token = crate::keyring_store::get_channel_token(id).ok_or_else(|| {
AppCommandError::configuration_missing("Bot token not set")
})?;
Box::new(TelegramBackend::new(id, bot_token, chat_id))
}
ChannelType::Lark => {
let config: serde_json::Value =
serde_json::from_str(&model.config_json).map_err(|e| {
AppCommandError::configuration_invalid("Invalid config JSON")
.with_detail(e.to_string())
})?;
let app_id = config
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppCommandError::configuration_missing("app_id is required"))?
.to_string();
let chat_id = config
.get("chat_id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppCommandError::configuration_missing("chat_id is required"))?
.to_string();
let app_secret = crate::keyring_store::get_channel_token(id).ok_or_else(|| {
AppCommandError::configuration_missing("App Secret not set")
})?;
Box::new(LarkBackend::new(id, app_id, app_secret, chat_id))
}
};
manager
.add_channel(id, model.name, channel_type, backend)
.await
.map_err(AppCommandError::from)?;
Ok(())
}
pub async fn test_chat_channel_core(
db: &AppDatabase,
id: i32,
) -> Result<(), AppCommandError> {
let model = chat_channel_service::get_by_id(&db.conn, id)
.await
.map_err(AppCommandError::from)?
.ok_or_else(|| AppCommandError::not_found(format!("Chat channel {id} not found")))?;
let channel_type: ChannelType =
serde_json::from_value(serde_json::Value::String(model.channel_type.clone()))
.map_err(|_| {
AppCommandError::configuration_invalid(format!(
"Invalid channel type: {}",
model.channel_type
))
})?;
match channel_type {
ChannelType::Telegram => {
let config: serde_json::Value =
serde_json::from_str(&model.config_json).map_err(|e| {
AppCommandError::configuration_invalid("Invalid config JSON")
.with_detail(e.to_string())
})?;
let chat_id = config
.get("chat_id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppCommandError::configuration_missing("chat_id is required"))?
.to_string();
let bot_token = crate::keyring_store::get_channel_token(id).ok_or_else(|| {
AppCommandError::configuration_missing("Bot token not set")
})?;
let backend = TelegramBackend::new(id, bot_token, chat_id);
backend
.test_connection()
.await
.map_err(AppCommandError::from)?;
}
ChannelType::Lark => {
let config: serde_json::Value =
serde_json::from_str(&model.config_json).map_err(|e| {
AppCommandError::configuration_invalid("Invalid config JSON")
.with_detail(e.to_string())
})?;
let app_id = config
.get("app_id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppCommandError::configuration_missing("app_id is required"))?
.to_string();
let chat_id = config
.get("chat_id")
.and_then(|v| v.as_str())
.ok_or_else(|| AppCommandError::configuration_missing("chat_id is required"))?
.to_string();
let app_secret = crate::keyring_store::get_channel_token(id).ok_or_else(|| {
AppCommandError::configuration_missing("App Secret not set")
})?;
let backend = LarkBackend::new(id, app_id, app_secret, chat_id);
backend
.test_connection()
.await
.map_err(AppCommandError::from)?;
}
}
Ok(())
}
pub fn save_chat_channel_token_core(channel_id: i32, token: &str) -> Result<(), AppCommandError> {
crate::keyring_store::set_channel_token(channel_id, token)
.map_err(|e| AppCommandError::io_error("Failed to save token").with_detail(e))
}
pub fn get_chat_channel_has_token_core(channel_id: i32) -> Result<bool, AppCommandError> {
Ok(crate::keyring_store::get_channel_token(channel_id).is_some())
}
pub fn delete_chat_channel_token_core(channel_id: i32) -> Result<(), AppCommandError> {
crate::keyring_store::delete_channel_token(channel_id)
.map_err(|e| AppCommandError::io_error("Failed to delete token").with_detail(e))
}
pub async fn disconnect_chat_channel_core(
manager: &ChatChannelManager,
id: i32,
) -> Result<(), AppCommandError> {
manager
.remove_channel(id)
.await
.map_err(AppCommandError::from)?;
Ok(())
}
pub async fn get_chat_channel_status_core(
manager: &ChatChannelManager,
) -> Result<Vec<ChannelStatusInfo>, AppCommandError> {
Ok(manager.get_status().await)
}
pub async fn list_chat_channel_messages_core(
db: &AppDatabase,
channel_id: i32,
limit: Option<u64>,
offset: Option<u64>,
) -> Result<Vec<ChatChannelMessageLogInfo>, AppCommandError> {
let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0);
let rows = chat_channel_message_log_service::list_by_channel(&db.conn, channel_id, limit, offset)
.await
.map_err(AppCommandError::from)?;
Ok(rows.into_iter().map(ChatChannelMessageLogInfo::from).collect())
}
// ---------------------------------------------------------------------------
// Tauri commands (use tauri::State for injection)
// ---------------------------------------------------------------------------
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn list_chat_channels(
db: tauri::State<'_, AppDatabase>,
) -> Result<Vec<ChatChannelInfo>, AppCommandError> {
list_chat_channels_core(&db).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn create_chat_channel(
db: tauri::State<'_, AppDatabase>,
name: String,
channel_type: String,
config_json: String,
enabled: bool,
daily_report_enabled: bool,
daily_report_time: Option<String>,
) -> Result<ChatChannelInfo, AppCommandError> {
create_chat_channel_core(&db, name, channel_type, config_json, enabled, daily_report_enabled, daily_report_time).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn update_chat_channel(
db: tauri::State<'_, AppDatabase>,
id: i32,
name: Option<String>,
enabled: Option<bool>,
config_json: Option<String>,
event_filter_json: Option<Option<String>>,
daily_report_enabled: Option<bool>,
daily_report_time: Option<Option<String>>,
) -> Result<ChatChannelInfo, AppCommandError> {
update_chat_channel_core(&db, id, name, enabled, config_json, event_filter_json, daily_report_enabled, daily_report_time).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn delete_chat_channel(
db: tauri::State<'_, AppDatabase>,
id: i32,
) -> Result<(), AppCommandError> {
delete_chat_channel_core(&db, id).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn save_chat_channel_token(channel_id: i32, token: String) -> Result<(), AppCommandError> {
save_chat_channel_token_core(channel_id, &token)
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn get_chat_channel_has_token(channel_id: i32) -> Result<bool, AppCommandError> {
get_chat_channel_has_token_core(channel_id)
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn delete_chat_channel_token(channel_id: i32) -> Result<(), AppCommandError> {
delete_chat_channel_token_core(channel_id)
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn connect_chat_channel(
db: tauri::State<'_, AppDatabase>,
manager: tauri::State<'_, ChatChannelManager>,
id: i32,
) -> Result<(), AppCommandError> {
connect_chat_channel_core(&db, &manager, id).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn disconnect_chat_channel(
manager: tauri::State<'_, ChatChannelManager>,
id: i32,
) -> Result<(), AppCommandError> {
disconnect_chat_channel_core(&manager, id).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn test_chat_channel(
db: tauri::State<'_, AppDatabase>,
id: i32,
) -> Result<(), AppCommandError> {
test_chat_channel_core(&db, id).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn get_chat_channel_status(
manager: tauri::State<'_, ChatChannelManager>,
) -> Result<Vec<ChannelStatusInfo>, AppCommandError> {
get_chat_channel_status_core(&manager).await
}
#[cfg(feature = "tauri-runtime")]
#[tauri::command]
pub async fn list_chat_channel_messages(
db: tauri::State<'_, AppDatabase>,
channel_id: i32,
limit: Option<u64>,
offset: Option<u64>,
) -> Result<Vec<ChatChannelMessageLogInfo>, AppCommandError> {
list_chat_channel_messages_core(&db, channel_id, limit, offset).await
}

View File

@@ -1,4 +1,5 @@
pub mod acp;
pub mod chat_channel;
pub mod conversations;
pub mod folder_commands;
pub mod folders;

View File

@@ -0,0 +1,31 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "chat_channel")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub channel_type: String,
pub enabled: bool,
pub config_json: String,
pub event_filter_json: Option<String>,
pub daily_report_enabled: bool,
pub daily_report_time: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::chat_channel_message_log::Entity")]
MessageLogs,
}
impl Related<super::chat_channel_message_log::Entity> for Entity {
fn to() -> RelationDef {
Relation::MessageLogs.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,33 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "chat_channel_message_log")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub channel_id: i32,
pub direction: String,
pub message_type: String,
pub content_preview: String,
pub status: String,
pub error_detail: Option<String>,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::chat_channel::Entity",
from = "Column::ChannelId",
to = "super::chat_channel::Column::Id"
)]
ChatChannel,
}
impl Related<super::chat_channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::ChatChannel.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,5 +1,7 @@
pub mod agent_setting;
pub mod app_metadata;
pub mod chat_channel;
pub mod chat_channel_message_log;
pub mod conversation;
pub mod folder;
pub mod folder_command;

View File

@@ -2,6 +2,8 @@
pub use super::agent_setting::Entity as AgentSetting;
pub use super::app_metadata::Entity as AppMetadata;
pub use super::chat_channel::Entity as ChatChannel;
pub use super::chat_channel_message_log::Entity as ChatChannelMessageLog;
pub use super::conversation::Entity as Conversation;
pub use super::folder::Entity as Folder;
pub use super::folder_command::Entity as FolderCommand;

View File

@@ -0,0 +1,179 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// ─── chat_channel ───
manager
.create_table(
Table::create()
.table(ChatChannel::Table)
.if_not_exists()
.col(
ColumnDef::new(ChatChannel::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(ChatChannel::Name).string().not_null())
.col(
ColumnDef::new(ChatChannel::ChannelType)
.string()
.not_null(),
)
.col(
ColumnDef::new(ChatChannel::Enabled)
.boolean()
.not_null()
.default(true),
)
.col(
ColumnDef::new(ChatChannel::ConfigJson)
.text()
.not_null(),
)
.col(ColumnDef::new(ChatChannel::EventFilterJson).text().null())
.col(
ColumnDef::new(ChatChannel::DailyReportEnabled)
.boolean()
.not_null()
.default(false),
)
.col(
ColumnDef::new(ChatChannel::DailyReportTime)
.string()
.null(),
)
.col(
ColumnDef::new(ChatChannel::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(ChatChannel::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(),
)
.await?;
// ─── chat_channel_message_log ───
manager
.create_table(
Table::create()
.table(ChatChannelMessageLog::Table)
.if_not_exists()
.col(
ColumnDef::new(ChatChannelMessageLog::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(
ColumnDef::new(ChatChannelMessageLog::ChannelId)
.integer()
.not_null(),
)
.col(
ColumnDef::new(ChatChannelMessageLog::Direction)
.string()
.not_null(),
)
.col(
ColumnDef::new(ChatChannelMessageLog::MessageType)
.string()
.not_null(),
)
.col(
ColumnDef::new(ChatChannelMessageLog::ContentPreview)
.text()
.not_null(),
)
.col(
ColumnDef::new(ChatChannelMessageLog::Status)
.string()
.not_null(),
)
.col(
ColumnDef::new(ChatChannelMessageLog::ErrorDetail)
.text()
.null(),
)
.col(
ColumnDef::new(ChatChannelMessageLog::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.foreign_key(
ForeignKey::create()
.name("fk_ccml_channel_id")
.from(
ChatChannelMessageLog::Table,
ChatChannelMessageLog::ChannelId,
)
.to(ChatChannel::Table, ChatChannel::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_ccml_channel_created")
.table(ChatChannelMessageLog::Table)
.col(ChatChannelMessageLog::ChannelId)
.col(ChatChannelMessageLog::CreatedAt)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(
Table::drop()
.table(ChatChannelMessageLog::Table)
.to_owned(),
)
.await?;
manager
.drop_table(Table::drop().table(ChatChannel::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ChatChannel {
Table,
Id,
Name,
ChannelType,
Enabled,
ConfigJson,
EventFilterJson,
DailyReportEnabled,
DailyReportTime,
CreatedAt,
UpdatedAt,
}
#[derive(DeriveIden)]
enum ChatChannelMessageLog {
Table,
Id,
ChannelId,
Direction,
MessageType,
ContentPreview,
Status,
ErrorDetail,
CreatedAt,
}

View File

@@ -5,6 +5,7 @@ mod m20260219_000001_folder_command;
mod m20260221_000001_folder_is_open;
mod m20260226_000001_agent_setting;
mod m20260227_000001_folder_parent_branch;
mod m20260330_000001_chat_channel;
pub struct Migrator;
#[async_trait::async_trait]
@@ -16,6 +17,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260221_000001_folder_is_open::Migration),
Box::new(m20260226_000001_agent_setting::Migration),
Box::new(m20260227_000001_folder_parent_branch::Migration),
Box::new(m20260330_000001_chat_channel::Migration),
]
}
}

View File

@@ -0,0 +1,70 @@
use chrono::Utc;
use sea_orm::prelude::DateTimeUtc;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
QueryFilter, QueryOrder, Set,
};
use crate::db::entities::chat_channel_message_log;
use crate::db::error::DbError;
pub async fn create_log(
conn: &DatabaseConnection,
channel_id: i32,
direction: &str,
message_type: &str,
content_preview: &str,
status: &str,
error_detail: Option<String>,
) -> Result<(), DbError> {
let active = chat_channel_message_log::ActiveModel {
id: NotSet,
channel_id: Set(channel_id),
direction: Set(direction.to_string()),
message_type: Set(message_type.to_string()),
content_preview: Set(truncate_preview(content_preview)),
status: Set(status.to_string()),
error_detail: Set(error_detail),
created_at: Set(Utc::now()),
};
active.insert(conn).await?;
Ok(())
}
pub async fn list_by_channel(
conn: &DatabaseConnection,
channel_id: i32,
limit: u64,
offset: u64,
) -> Result<Vec<chat_channel_message_log::Model>, DbError> {
use sea_orm::PaginatorTrait;
Ok(chat_channel_message_log::Entity::find()
.filter(chat_channel_message_log::Column::ChannelId.eq(channel_id))
.order_by_desc(chat_channel_message_log::Column::CreatedAt)
.paginate(conn, limit)
.fetch_page(offset / limit)
.await?)
}
pub async fn cleanup_old_logs(
conn: &DatabaseConnection,
older_than: DateTimeUtc,
) -> Result<u64, DbError> {
let result = chat_channel_message_log::Entity::delete_many()
.filter(chat_channel_message_log::Column::CreatedAt.lt(older_than))
.exec(conn)
.await?;
Ok(result.rows_affected)
}
fn truncate_preview(s: &str) -> String {
if s.len() <= 200 {
s.to_string()
} else {
let mut end = 200;
while !s.is_char_boundary(end) && end > 0 {
end -= 1;
}
format!("{}...", &s[..end])
}
}

View File

@@ -0,0 +1,98 @@
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
IntoActiveModel, QueryFilter, QueryOrder, Set,
};
use crate::db::entities::chat_channel;
use crate::db::error::DbError;
pub async fn create(
conn: &DatabaseConnection,
name: String,
channel_type: String,
config_json: String,
enabled: bool,
daily_report_enabled: bool,
daily_report_time: Option<String>,
) -> Result<chat_channel::Model, DbError> {
let now = Utc::now();
let active = chat_channel::ActiveModel {
id: NotSet,
name: Set(name),
channel_type: Set(channel_type),
enabled: Set(enabled),
config_json: Set(config_json),
event_filter_json: Set(None),
daily_report_enabled: Set(daily_report_enabled),
daily_report_time: Set(daily_report_time),
created_at: Set(now),
updated_at: Set(now),
};
Ok(active.insert(conn).await?)
}
pub async fn update(
conn: &DatabaseConnection,
id: i32,
name: Option<String>,
enabled: Option<bool>,
config_json: Option<String>,
event_filter_json: Option<Option<String>>,
daily_report_enabled: Option<bool>,
daily_report_time: Option<Option<String>>,
) -> Result<chat_channel::Model, DbError> {
let model = chat_channel::Entity::find_by_id(id)
.one(conn)
.await?
.ok_or_else(|| DbError::Migration(format!("chat channel not found: {id}")))?;
let mut active = model.into_active_model();
if let Some(v) = name {
active.name = Set(v);
}
if let Some(v) = enabled {
active.enabled = Set(v);
}
if let Some(v) = config_json {
active.config_json = Set(v);
}
if let Some(v) = event_filter_json {
active.event_filter_json = Set(v);
}
if let Some(v) = daily_report_enabled {
active.daily_report_enabled = Set(v);
}
if let Some(v) = daily_report_time {
active.daily_report_time = Set(v);
}
active.updated_at = Set(Utc::now());
Ok(active.update(conn).await?)
}
pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> {
chat_channel::Entity::delete_by_id(id).exec(conn).await?;
Ok(())
}
pub async fn get_by_id(
conn: &DatabaseConnection,
id: i32,
) -> Result<Option<chat_channel::Model>, DbError> {
Ok(chat_channel::Entity::find_by_id(id).one(conn).await?)
}
pub async fn list_all(conn: &DatabaseConnection) -> Result<Vec<chat_channel::Model>, DbError> {
Ok(chat_channel::Entity::find()
.order_by_asc(chat_channel::Column::Id)
.all(conn)
.await?)
}
pub async fn list_enabled(conn: &DatabaseConnection) -> Result<Vec<chat_channel::Model>, DbError> {
Ok(chat_channel::Entity::find()
.filter(chat_channel::Column::Enabled.eq(true))
.order_by_asc(chat_channel::Column::Id)
.all(conn)
.await?)
}

View File

@@ -1,5 +1,7 @@
pub mod agent_setting_service;
pub mod app_metadata_service;
pub mod chat_channel_message_log_service;
pub mod chat_channel_service;
pub mod conversation_service;
pub mod folder_command_service;
pub mod folder_service;

View File

@@ -5,6 +5,10 @@ fn token_key(account_id: &str) -> String {
format!("github-token:{}", account_id)
}
fn channel_token_key(channel_id: i32) -> String {
format!("chat-channel:{}", channel_id)
}
// ── Tauri mode: OS keyring ──
#[cfg(feature = "tauri-runtime")]
@@ -87,3 +91,51 @@ pub fn delete_token(account_id: &str) -> Result<(), String> {
tokens.remove(&token_key(account_id));
write_tokens(&tokens)
}
// ── Chat channel token helpers ──
// Reuse the same storage mechanism (keyring or file) with a different key prefix.
#[cfg(feature = "tauri-runtime")]
pub fn set_channel_token(channel_id: i32, token: &str) -> Result<(), String> {
let entry = keyring::Entry::new(SERVICE_NAME, &channel_token_key(channel_id))
.map_err(|e| format!("keyring init error: {e}"))?;
entry
.set_password(token)
.map_err(|e| format!("keyring set error: {e}"))
}
#[cfg(feature = "tauri-runtime")]
pub fn get_channel_token(channel_id: i32) -> Option<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)
}

View File

@@ -1,6 +1,7 @@
mod acp;
mod app_error;
pub mod app_state;
pub mod chat_channel;
mod commands;
pub mod db;
pub mod git_credential;
@@ -17,10 +18,11 @@ mod tauri_app {
use std::sync::atomic::{AtomicBool, Ordering};
use crate::acp::manager::ConnectionManager;
use crate::chat_channel::manager::ChatChannelManager;
use crate::commands::{
acp as acp_commands, conversations, folder_commands, folders, mcp as mcp_commands,
notification, project_boot, system_settings, terminal as terminal_commands, version_control,
windows,
acp as acp_commands, chat_channel as chat_channel_commands, conversations, folder_commands,
folders, mcp as mcp_commands, notification, project_boot, system_settings,
terminal as terminal_commands, version_control, windows,
};
use crate::terminal::manager::TerminalManager;
use crate::{db, network, process, web};
@@ -52,6 +54,7 @@ mod tauri_app {
.plugin(tauri_plugin_notification::init())
.manage(ConnectionManager::new())
.manage(TerminalManager::new())
.manage(ChatChannelManager::new())
.manage(windows::SettingsWindowState::new())
.manage(windows::CommitWindowState::new())
.manage(windows::MergeWindowState::new())
@@ -78,6 +81,19 @@ mod tauri_app {
}
}
// Start chat channel background tasks
{
let ccm = app.state::<ChatChannelManager>();
let broadcaster = app
.state::<std::sync::Arc<web::event_bridge::WebEventBroadcaster>>();
let db_conn = app.state::<db::AppDatabase>().conn.clone();
let ccm_ref = ccm.clone_ref();
let br = broadcaster.inner().clone();
tauri::async_runtime::spawn(async move {
ccm_ref.start_background(br, db_conn).await;
});
}
// Restore previously open folders or show welcome
let db = app.state::<db::AppDatabase>();
let open_folders = tauri::async_runtime::block_on(
@@ -351,6 +367,18 @@ mod tauri_app {
mcp_commands::mcp_set_server_apps,
mcp_commands::mcp_remove_server,
notification::send_notification,
chat_channel_commands::list_chat_channels,
chat_channel_commands::create_chat_channel,
chat_channel_commands::update_chat_channel,
chat_channel_commands::delete_chat_channel,
chat_channel_commands::save_chat_channel_token,
chat_channel_commands::get_chat_channel_has_token,
chat_channel_commands::delete_chat_channel_token,
chat_channel_commands::connect_chat_channel,
chat_channel_commands::disconnect_chat_channel,
chat_channel_commands::test_chat_channel,
chat_channel_commands::get_chat_channel_status,
chat_channel_commands::list_chat_channel_messages,
web::start_web_server,
web::stop_web_server,
web::get_web_server_status,

View File

@@ -0,0 +1,67 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatChannelInfo {
pub id: i32,
pub name: String,
pub channel_type: String,
pub enabled: bool,
pub config_json: String,
pub event_filter_json: Option<String>,
pub daily_report_enabled: bool,
pub daily_report_time: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelStatusInfo {
pub channel_id: i32,
pub name: String,
pub channel_type: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatChannelMessageLogInfo {
pub id: i32,
pub channel_id: i32,
pub direction: String,
pub message_type: String,
pub content_preview: String,
pub status: String,
pub error_detail: Option<String>,
pub created_at: String,
}
impl From<crate::db::entities::chat_channel::Model> for ChatChannelInfo {
fn from(m: crate::db::entities::chat_channel::Model) -> Self {
Self {
id: m.id,
name: m.name,
channel_type: m.channel_type,
enabled: m.enabled,
config_json: m.config_json,
event_filter_json: m.event_filter_json,
daily_report_enabled: m.daily_report_enabled,
daily_report_time: m.daily_report_time,
created_at: m.created_at.to_rfc3339(),
updated_at: m.updated_at.to_rfc3339(),
}
}
}
impl From<crate::db::entities::chat_channel_message_log::Model> for ChatChannelMessageLogInfo {
fn from(m: crate::db::entities::chat_channel_message_log::Model) -> Self {
Self {
id: m.id,
channel_id: m.channel_id,
direction: m.direction,
message_type: m.message_type,
content_preview: m.content_preview,
status: m.status,
error_detail: m.error_detail,
created_at: m.created_at.to_rfc3339(),
}
}
}

View File

@@ -1,10 +1,13 @@
pub mod agent;
pub mod chat_channel;
pub mod conversation;
pub mod folder;
pub mod message;
pub mod system;
pub use agent::AgentType;
#[allow(unused_imports)]
pub use chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
pub use conversation::{
AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary,
DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats,

View File

@@ -0,0 +1,185 @@
use std::sync::Arc;
use axum::{extract::Extension, Json};
use serde::Deserialize;
use crate::app_error::AppCommandError;
use crate::app_state::AppState;
use crate::commands::chat_channel as cc_commands;
use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
// ---------------------------------------------------------------------------
// Param structs
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateChatChannelParams {
pub name: String,
pub channel_type: String,
pub config_json: String,
pub enabled: bool,
pub daily_report_enabled: bool,
pub daily_report_time: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateChatChannelParams {
pub id: i32,
pub name: Option<String>,
pub enabled: Option<bool>,
pub config_json: Option<String>,
pub event_filter_json: Option<Option<String>>,
pub daily_report_enabled: Option<bool>,
pub daily_report_time: Option<Option<String>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelIdParams {
pub id: i32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveTokenParams {
pub channel_id: i32,
pub token: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelIdOnlyParams {
pub channel_id: i32,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListMessagesParams {
pub channel_id: i32,
pub limit: Option<u64>,
pub offset: Option<u64>,
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
pub async fn list_chat_channels(
Extension(state): Extension<Arc<AppState>>,
) -> Result<Json<Vec<ChatChannelInfo>>, AppCommandError> {
let result = cc_commands::list_chat_channels_core(&state.db).await?;
Ok(Json(result))
}
pub async fn create_chat_channel(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<CreateChatChannelParams>,
) -> Result<Json<ChatChannelInfo>, AppCommandError> {
let result = cc_commands::create_chat_channel_core(
&state.db,
params.name,
params.channel_type,
params.config_json,
params.enabled,
params.daily_report_enabled,
params.daily_report_time,
)
.await?;
Ok(Json(result))
}
pub async fn update_chat_channel(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<UpdateChatChannelParams>,
) -> Result<Json<ChatChannelInfo>, AppCommandError> {
let result = cc_commands::update_chat_channel_core(
&state.db,
params.id,
params.name,
params.enabled,
params.config_json,
params.event_filter_json,
params.daily_report_enabled,
params.daily_report_time,
)
.await?;
Ok(Json(result))
}
pub async fn delete_chat_channel(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<ChannelIdParams>,
) -> Result<Json<()>, AppCommandError> {
cc_commands::delete_chat_channel_core(&state.db, params.id).await?;
Ok(Json(()))
}
pub async fn save_chat_channel_token(
Json(params): Json<SaveTokenParams>,
) -> Result<Json<()>, AppCommandError> {
cc_commands::save_chat_channel_token_core(params.channel_id, &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))
}

View File

@@ -1,13 +1,14 @@
mod error;
pub mod acp;
pub mod chat_channel;
pub mod conversations;
pub mod files;
pub mod folders;
pub mod acp;
pub mod terminal;
pub mod system_settings;
pub mod version_control;
pub mod folder_commands;
pub mod mcp;
pub mod folders;
pub mod git;
pub mod mcp;
pub mod project_boot;
pub mod system_settings;
pub mod terminal;
pub mod version_control;
pub mod web_server;

View File

@@ -245,6 +245,7 @@ pub async fn start_web_server(
emitter: crate::web::event_bridge::EventEmitter::Tauri(app.clone()),
data_dir: app.path().app_data_dir().unwrap_or_default(),
web_server_state: WebServerState::new(), // placeholder; not used by handlers
chat_channel_manager: crate::app_state::default_chat_channel_manager(),
});
let router = router::build_router(app_state, token.clone(), static_dir);

View File

@@ -180,6 +180,19 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
.route("/start_web_server", post(handlers::web_server::start_web_server))
.route("/stop_web_server", post(handlers::web_server::stop_web_server))
.route("/check_app_update", post(handlers::web_server::check_app_update))
// ─── Chat Channels ───
.route("/list_chat_channels", post(handlers::chat_channel::list_chat_channels))
.route("/create_chat_channel", post(handlers::chat_channel::create_chat_channel))
.route("/update_chat_channel", post(handlers::chat_channel::update_chat_channel))
.route("/delete_chat_channel", post(handlers::chat_channel::delete_chat_channel))
.route("/save_chat_channel_token", post(handlers::chat_channel::save_chat_channel_token))
.route("/get_chat_channel_has_token", post(handlers::chat_channel::get_chat_channel_has_token))
.route("/delete_chat_channel_token", post(handlers::chat_channel::delete_chat_channel_token))
.route("/connect_chat_channel", post(handlers::chat_channel::connect_chat_channel))
.route("/disconnect_chat_channel", post(handlers::chat_channel::disconnect_chat_channel))
.route("/test_chat_channel", post(handlers::chat_channel::test_chat_channel))
.route("/get_chat_channel_status", post(handlers::chat_channel::get_chat_channel_status))
.route("/list_chat_channel_messages", post(handlers::chat_channel::list_chat_channel_messages))
// ─── Terminal ───
.route("/terminal_spawn", post(handlers::terminal::terminal_spawn))
.route("/terminal_write", post(handlers::terminal::terminal_write))

View File

@@ -0,0 +1,5 @@
import { ChatChannelSettings } from "@/components/settings/chat-channel-settings"
export default function SettingsChatChannelsPage() {
return <ChatChannelSettings />
}

View File

@@ -0,0 +1,245 @@
"use client"
import { useCallback, useState } from "react"
import { Loader2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import {
createChatChannel,
saveChatChannelToken,
} from "@/lib/api"
import type { ChannelType } from "@/lib/types"
interface AddChatChannelDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onChannelAdded: () => void
}
export function AddChatChannelDialog({
open,
onOpenChange,
onChannelAdded,
}: AddChatChannelDialogProps) {
const t = useTranslations("ChatChannelSettings")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState("")
const [channelType, setChannelType] = useState<ChannelType>("telegram")
const [token, setToken] = useState("")
const [chatId, setChatId] = useState("")
const [appId, setAppId] = useState("")
const [dailyReportEnabled, setDailyReportEnabled] = useState(false)
const [dailyReportTime, setDailyReportTime] = useState("18:00")
const resetForm = useCallback(() => {
setName("")
setChannelType("telegram")
setToken("")
setChatId("")
setAppId("")
setDailyReportEnabled(false)
setDailyReportTime("18:00")
setError(null)
}, [])
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) resetForm()
onOpenChange(nextOpen)
},
[onOpenChange, resetForm],
)
const handleSubmit = useCallback(async () => {
if (!name.trim()) {
setError(t("nameRequired"))
return
}
if (!token.trim()) {
setError(t("tokenRequired"))
return
}
if (!chatId.trim()) {
setError(t("chatIdRequired"))
return
}
setLoading(true)
setError(null)
try {
const configJson =
channelType === "lark"
? JSON.stringify({ app_id: appId, chat_id: chatId })
: JSON.stringify({ chat_id: chatId })
const channel = await createChatChannel({
name: name.trim(),
channelType,
configJson,
enabled: true,
dailyReportEnabled,
dailyReportTime: dailyReportEnabled ? dailyReportTime : null,
})
await saveChatChannelToken(channel.id, token.trim())
handleOpenChange(false)
onChannelAdded()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
setError(msg)
} finally {
setLoading(false)
}
}, [
name,
token,
chatId,
channelType,
appId,
dailyReportEnabled,
dailyReportTime,
handleOpenChange,
onChannelAdded,
t,
])
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("addChannel")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-medium">{t("channelName")}</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("channelNamePlaceholder")}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium">{t("channelType")}</label>
<Select
value={channelType}
onValueChange={(v) => setChannelType(v as ChannelType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="lark">
{t("lark")}
</SelectItem>
</SelectContent>
</Select>
</div>
{channelType === "lark" && (
<div className="space-y-1.5">
<label className="text-xs font-medium">App ID</label>
<Input
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="cli_xxxxx"
/>
</div>
)}
<div className="space-y-1.5">
<label className="text-xs font-medium">
{channelType === "telegram" ? "Bot Token" : "App Secret"}
</label>
<Input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={
channelType === "telegram"
? "123456:ABC-DEF..."
: "xxxxx"
}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium">Chat ID</label>
<Input
value={chatId}
onChange={(e) => setChatId(e.target.value)}
placeholder={
channelType === "telegram" ? "-100123456789" : "oc_xxxxx"
}
/>
</div>
<div className="flex items-center justify-between">
<label className="text-xs font-medium">
{t("dailyReport")}
</label>
<Switch
checked={dailyReportEnabled}
onCheckedChange={setDailyReportEnabled}
/>
</div>
{dailyReportEnabled && (
<div className="space-y-1.5">
<label className="text-xs font-medium">
{t("dailyReportTime")}
</label>
<Input
type="time"
value={dailyReportTime}
onChange={(e) => setDailyReportTime(e.target.value)}
/>
</div>
)}
{error && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{error}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={loading}
>
{t("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />}
{t("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,300 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import {
Loader2,
MessageCircle,
Plus,
Power,
PowerOff,
TestTube,
Trash2,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
listChatChannels,
deleteChatChannel,
connectChatChannel,
disconnectChatChannel,
testChatChannel,
updateChatChannel,
getChatChannelStatus,
} from "@/lib/api"
import type { ChatChannelInfo, ChannelStatusInfo } from "@/lib/types"
import { AddChatChannelDialog } from "./add-chat-channel-dialog"
export function ChatChannelSettings() {
const t = useTranslations("ChatChannelSettings")
const [channels, setChannels] = useState<ChatChannelInfo[]>([])
const [statuses, setStatuses] = useState<ChannelStatusInfo[]>([])
const [loading, setLoading] = useState(true)
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<ChatChannelInfo | null>(null)
const [actionLoading, setActionLoading] = useState<number | null>(null)
const loadChannels = useCallback(async () => {
try {
const [chs, sts] = await Promise.all([
listChatChannels(),
getChatChannelStatus().catch(() => []),
])
setChannels(chs)
setStatuses(sts)
} catch (err) {
toast.error(t("loadFailed"))
} finally {
setLoading(false)
}
}, [t])
useEffect(() => {
loadChannels().catch(console.error)
}, [loadChannels])
const handleToggleEnabled = useCallback(
async (ch: ChatChannelInfo) => {
try {
await updateChatChannel({ id: ch.id, enabled: !ch.enabled })
await loadChannels()
} catch (err) {
toast.error(t("saveFailed"))
}
},
[loadChannels, t],
)
const handleConnect = useCallback(
async (id: number) => {
setActionLoading(id)
try {
await connectChatChannel(id)
toast.success(t("connectSuccess"))
await loadChannels()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
toast.error(t("connectFailed") + ": " + msg)
} finally {
setActionLoading(null)
}
},
[loadChannels, t],
)
const handleDisconnect = useCallback(
async (id: number) => {
setActionLoading(id)
try {
await disconnectChatChannel(id)
toast.success(t("disconnectSuccess"))
await loadChannels()
} catch (err) {
toast.error(t("disconnectFailed"))
} finally {
setActionLoading(null)
}
},
[loadChannels, t],
)
const handleTest = useCallback(
async (id: number) => {
setActionLoading(id)
try {
await testChatChannel(id)
toast.success(t("testSuccess"))
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
toast.error(t("testFailed") + ": " + msg)
} finally {
setActionLoading(null)
}
},
[t],
)
const handleDelete = useCallback(async () => {
if (!deleteTarget) return
try {
await deleteChatChannel(deleteTarget.id)
toast.success(t("deleteSuccess"))
setDeleteTarget(null)
await loadChannels()
} catch (err) {
toast.error(t("deleteFailed"))
}
}, [deleteTarget, loadChannels, t])
const getChannelStatus = (id: number) =>
statuses.find((s) => s.channel_id === id)?.status ?? "disconnected"
if (loading) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
{t("loading")}
</div>
)
}
return (
<div className="h-full overflow-auto">
<div className="w-full space-y-4">
<section className="space-y-1">
<div className="flex items-center justify-between">
<div>
<h1 className="text-sm font-semibold">{t("sectionTitle")}</h1>
<p className="text-sm text-muted-foreground">
{t("sectionDescription")}
</p>
</div>
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />
{t("addChannel")}
</Button>
</div>
</section>
{channels.length === 0 ? (
<section className="rounded-xl border bg-card p-8 text-center">
<MessageCircle className="h-8 w-8 mx-auto text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
{t("noChannels")}
</p>
</section>
) : (
<section className="space-y-2">
{channels.map((ch) => {
const status = getChannelStatus(ch.id)
const isConnected = status === "connected"
const isLoading = actionLoading === ch.id
return (
<div
key={ch.id}
className="rounded-xl border bg-card p-4 flex items-center gap-4"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{ch.name}</span>
<Badge variant="outline" className="text-xs">
{ch.channel_type}
</Badge>
<span
className={`inline-block h-2 w-2 rounded-full ${
isConnected
? "bg-green-500"
: status === "connecting"
? "bg-yellow-500 animate-pulse"
: status === "error"
? "bg-red-500"
: "bg-gray-400"
}`}
/>
</div>
<div className="flex items-center gap-3 mt-1">
{ch.daily_report_enabled && (
<span className="text-xs text-muted-foreground">
{t("dailyReport")}: {ch.daily_report_time || "18:00"}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={ch.enabled}
onCheckedChange={() => handleToggleEnabled(ch)}
/>
{isConnected ? (
<Button
variant="ghost"
size="sm"
disabled={isLoading}
onClick={() => handleDisconnect(ch.id)}
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<PowerOff className="h-3.5 w-3.5" />
)}
</Button>
) : (
<Button
variant="ghost"
size="sm"
disabled={isLoading || !ch.enabled}
onClick={() => handleConnect(ch.id)}
>
{isLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Power className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
variant="ghost"
size="sm"
disabled={isLoading}
onClick={() => handleTest(ch.id)}
>
<TestTube className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget(ch)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
)
})}
</section>
)}
</div>
<AddChatChannelDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
onChannelAdded={loadChannels}
/>
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteConfirmMessage")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -12,6 +12,7 @@ import {
GitBranch,
Globe,
Keyboard,
MessageCircle,
Palette,
PlugZap,
Settings,
@@ -33,6 +34,7 @@ interface SettingsNavItem {
| "skills"
| "shortcuts"
| "version_control"
| "chat_channels"
| "system"
| "web_service"
icon: ComponentType<{ className?: string }>
@@ -69,6 +71,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
labelKey: "version_control",
icon: GitBranch,
},
{
href: "/settings/chat-channels",
labelKey: "chat_channels",
icon: MessageCircle,
},
{
href: "/settings/web-service",
labelKey: "web_service",

View File

@@ -92,6 +92,7 @@
"shortcuts": "Shortcuts",
"version_control": "Version Control",
"system": "System",
"chat_channels": "Chat Channels",
"web_service": "Web Service"
}
},
@@ -1670,5 +1671,36 @@
"emptyDirectory": "This directory is empty",
"errorLoadingDir": "Failed to load directory",
"permissionDenied": "Permission denied"
},
"ChatChannelSettings": {
"loading": "Loading...",
"sectionTitle": "Chat Channels",
"sectionDescription": "Configure IM bots to receive event notifications and query coding activity.",
"addChannel": "Add Channel",
"noChannels": "No chat channels configured yet.",
"channelName": "Name",
"channelNamePlaceholder": "My Telegram Bot",
"channelType": "Channel Type",
"lark": "Lark (Feishu)",
"dailyReport": "Daily Report",
"dailyReportTime": "Report Time",
"nameRequired": "Channel name is required.",
"tokenRequired": "Token is required.",
"chatIdRequired": "Chat ID is required.",
"loadFailed": "Failed to load channels.",
"saveFailed": "Failed to save changes.",
"connectSuccess": "Channel connected.",
"connectFailed": "Failed to connect",
"disconnectSuccess": "Channel disconnected.",
"disconnectFailed": "Failed to disconnect.",
"testSuccess": "Connection test passed.",
"testFailed": "Connection test failed",
"deleteSuccess": "Channel deleted.",
"deleteFailed": "Failed to delete channel.",
"deleteConfirmTitle": "Delete Channel",
"deleteConfirmMessage": "This will permanently delete the channel and its message logs. Are you sure?",
"cancel": "Cancel",
"delete": "Delete",
"create": "Create"
}
}

View File

@@ -92,6 +92,7 @@
"shortcuts": "快捷键",
"version_control": "版本控制",
"system": "系统",
"chat_channels": "消息渠道",
"web_service": "Web 服务"
}
},
@@ -1670,5 +1671,36 @@
"emptyDirectory": "此目录为空",
"errorLoadingDir": "加载目录失败",
"permissionDenied": "权限不足"
},
"ChatChannelSettings": {
"loading": "加载中...",
"sectionTitle": "消息渠道",
"sectionDescription": "配置 IM 机器人,接收事件通知和查询编码活动。",
"addChannel": "添加渠道",
"noChannels": "尚未配置任何消息渠道。",
"channelName": "名称",
"channelNamePlaceholder": "我的 Telegram 机器人",
"channelType": "渠道类型",
"lark": "飞书",
"dailyReport": "每日报告",
"dailyReportTime": "推送时间",
"nameRequired": "请输入渠道名称。",
"tokenRequired": "请输入 Token。",
"chatIdRequired": "请输入 Chat ID。",
"loadFailed": "加载渠道失败。",
"saveFailed": "保存失败。",
"connectSuccess": "渠道已连接。",
"connectFailed": "连接失败",
"disconnectSuccess": "渠道已断开。",
"disconnectFailed": "断开连接失败。",
"testSuccess": "连接测试通过。",
"testFailed": "连接测试失败",
"deleteSuccess": "渠道已删除。",
"deleteFailed": "删除渠道失败。",
"deleteConfirmTitle": "删除渠道",
"deleteConfirmMessage": "将永久删除该渠道及其消息日志,确定吗?",
"cancel": "取消",
"delete": "删除",
"create": "创建"
}
}

View File

@@ -54,6 +54,9 @@ import type {
McpMarketplaceProvider,
McpMarketplaceItem,
McpMarketplaceServerDetail,
ChatChannelInfo,
ChannelStatusInfo,
ChatChannelMessageLog,
} from "./types"
export async function listConversations(params?: {
@@ -1304,3 +1307,98 @@ export async function stopWebServer(): Promise<void> {
export async function getWebServerStatus(): Promise<WebServerInfo | null> {
return getTransport().call("get_web_server_status")
}
// ─── Chat Channels ───
export async function listChatChannels(): Promise<ChatChannelInfo[]> {
return getTransport().call("list_chat_channels")
}
export async function createChatChannel(params: {
name: string
channelType: string
configJson: string
enabled: boolean
dailyReportEnabled: boolean
dailyReportTime?: string | null
}): Promise<ChatChannelInfo> {
return getTransport().call("create_chat_channel", {
name: params.name,
channelType: params.channelType,
configJson: params.configJson,
enabled: params.enabled,
dailyReportEnabled: params.dailyReportEnabled,
dailyReportTime: params.dailyReportTime ?? null,
})
}
export async function updateChatChannel(params: {
id: number
name?: string | null
enabled?: boolean | null
configJson?: string | null
eventFilterJson?: string | null
dailyReportEnabled?: boolean | null
dailyReportTime?: string | null
}): Promise<ChatChannelInfo> {
return getTransport().call("update_chat_channel", {
id: params.id,
name: params.name ?? null,
enabled: params.enabled ?? null,
configJson: params.configJson ?? null,
eventFilterJson: params.eventFilterJson ?? null,
dailyReportEnabled: params.dailyReportEnabled ?? null,
dailyReportTime: params.dailyReportTime ?? null,
})
}
export async function deleteChatChannel(id: number): Promise<void> {
return getTransport().call("delete_chat_channel", { id })
}
export async function saveChatChannelToken(
channelId: number,
token: string,
): Promise<void> {
return getTransport().call("save_chat_channel_token", { channelId, token })
}
export async function getChatChannelHasToken(
channelId: number,
): Promise<boolean> {
return getTransport().call("get_chat_channel_has_token", { channelId })
}
export async function deleteChatChannelToken(
channelId: number,
): Promise<void> {
return getTransport().call("delete_chat_channel_token", { channelId })
}
export async function connectChatChannel(id: number): Promise<void> {
return getTransport().call("connect_chat_channel", { id })
}
export async function disconnectChatChannel(id: number): Promise<void> {
return getTransport().call("disconnect_chat_channel", { id })
}
export async function testChatChannel(id: number): Promise<void> {
return getTransport().call("test_chat_channel", { id })
}
export async function getChatChannelStatus(): Promise<ChannelStatusInfo[]> {
return getTransport().call("get_chat_channel_status")
}
export async function listChatChannelMessages(params: {
channelId: number
limit?: number
offset?: number
}): Promise<ChatChannelMessageLog[]> {
return getTransport().call("list_chat_channel_messages", {
channelId: params.channelId,
limit: params.limit ?? null,
offset: params.offset ?? null,
})
}

View File

@@ -841,3 +841,44 @@ export interface PreflightResult {
passed: boolean
checks: CheckItem[]
}
// ─── Chat Channels ───
export type ChannelType = "lark" | "telegram"
export type ChannelConnectionStatus =
| "connected"
| "connecting"
| "disconnected"
| "error"
export interface ChatChannelInfo {
id: number
name: string
channel_type: ChannelType
enabled: boolean
config_json: string
event_filter_json: string | null
daily_report_enabled: boolean
daily_report_time: string | null
created_at: string
updated_at: string
}
export interface ChannelStatusInfo {
channel_id: number
name: string
channel_type: ChannelType
status: ChannelConnectionStatus
}
export interface ChatChannelMessageLog {
id: number
channel_id: number
direction: "outbound" | "inbound"
message_type: string
content_preview: string
status: "sent" | "failed"
error_detail: string | null
created_at: string
}