chore(lint): clean up frontend and Rust lint issues

This commit is contained in:
xintaofei
2026-04-23 15:56:41 +08:00
parent 1dd40d0baf
commit 022172a9ea
69 changed files with 1138 additions and 1248 deletions
+2 -7
View File
@@ -202,9 +202,7 @@ impl LarkBackend {
let token = result
.tenant_access_token
.ok_or_else(|| {
ChatChannelError::AuthenticationFailed("No token in response".into())
})?;
.ok_or_else(|| ChatChannelError::AuthenticationFailed("No token in response".into()))?;
let expire_secs = result.expire.unwrap_or(7200);
let expires_at = Instant::now()
@@ -252,10 +250,7 @@ impl LarkBackend {
)));
}
let message_id = result
.data
.and_then(|d| d.message_id)
.unwrap_or_default();
let message_id = result.data.and_then(|d| d.message_id).unwrap_or_default();
Ok(SentMessageId(message_id))
}
@@ -34,10 +34,7 @@ impl TelegramBackend {
}
fn api_url(&self, method: &str) -> String {
format!(
"https://api.telegram.org/bot{}/{}",
self.bot_token, method
)
format!("https://api.telegram.org/bot{}/{}", self.bot_token, method)
}
async fn send_text(
@@ -171,9 +168,8 @@ impl ChatChannelBackend for TelegramBackend {
{
offset = uid + 1;
}
if let Some(text) = update
.pointer("/message/text")
.and_then(|t| t.as_str())
if let Some(text) =
update.pointer("/message/text").and_then(|t| t.as_str())
{
// Group chat filtering: only process if @bot is mentioned
let chat_type = update
@@ -184,8 +180,7 @@ impl ChatChannelBackend for TelegramBackend {
if (chat_type == "group" || chat_type == "supergroup")
&& !bot_username.is_empty()
{
let at_bot =
format!("@{}", bot_username);
let at_bot = format!("@{}", bot_username);
if !text.to_lowercase().contains(&at_bot) {
eprintln!("[Telegram] skipped group msg without @bot: {text}");
continue;
+59 -76
View File
@@ -57,6 +57,18 @@ pub struct WeixinQrcodeStatusPublic {
pub status: String,
}
struct SendRequest<'a> {
client: &'a reqwest::Client,
base_url: &'a str,
bot_token: &'a str,
wechat_uin: &'a str,
to_user_id: &'a str,
context_token: &'a str,
text: &'a str,
reply_context: &'a Mutex<Option<WeixinReplyContext>>,
pending_messages: &'a Mutex<Vec<String>>,
}
// ── QR code auth functions (called before backend exists) ──
pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, ChatChannelError> {
@@ -186,16 +198,16 @@ fn generate_qrcode_data_uri(content: &str) -> Result<String, ChatChannelError> {
Ok(format!("data:image/png;base64,{b64}"))
}
pub async fn weixin_check_qrcode(
qrcode: &str,
) -> Result<WeixinQrcodeStatus, ChatChannelError> {
pub async fn weixin_check_qrcode(qrcode: &str) -> Result<WeixinQrcodeStatus, ChatChannelError> {
let client = qr_client();
let resp = client
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status"))
.query(&[("qrcode", qrcode)])
.send()
.await
.map_err(|e| ChatChannelError::ConnectionFailed(format!("QR status request failed: {e}")))?;
.map_err(|e| {
ChatChannelError::ConnectionFailed(format!("QR status request failed: {e}"))
})?;
let body: serde_json::Value = resp
.json()
@@ -289,11 +301,7 @@ impl WeixinBackend {
}
/// Build the JSON body for the iLink sendmessage API.
fn build_send_body(
to_user_id: &str,
context_token: &str,
text: &str,
) -> serde_json::Value {
fn build_send_body(to_user_id: &str, context_token: &str, text: &str) -> serde_json::Value {
serde_json::json!({
"msg": {
"from_user_id": "",
@@ -313,23 +321,14 @@ impl WeixinBackend {
/// Send a message via the iLink API and handle the response.
/// Returns `Ok(true)` if sent, `Ok(false)` if buffered due to expired context.
async fn do_send(
client: &reqwest::Client,
base_url: &str,
bot_token: &str,
wechat_uin: &str,
to_user_id: &str,
context_token: &str,
text: &str,
reply_context: &Mutex<Option<WeixinReplyContext>>,
pending_messages: &Mutex<Vec<String>>,
) -> Result<bool, ChatChannelError> {
let body = Self::build_send_body(to_user_id, context_token, text);
let url = format!("{base_url}/ilink/bot/sendmessage");
async fn do_send(req: SendRequest<'_>) -> Result<bool, ChatChannelError> {
let body = Self::build_send_body(req.to_user_id, req.context_token, req.text);
let url = format!("{}/ilink/bot/sendmessage", req.base_url);
let resp = client
let resp = req
.client
.post(&url)
.headers(Self::build_headers(bot_token, wechat_uin))
.headers(Self::build_headers(req.bot_token, req.wechat_uin))
.json(&body)
.send()
.await
@@ -356,20 +355,18 @@ impl WeixinBackend {
if ret == -2 {
// Context token expired — mark stale and buffer
if let Some(ref mut c) = *reply_context.lock().await {
if let Some(ref mut c) = *req.reply_context.lock().await {
c.expired = true;
}
let mut buf = pending_messages.lock().await;
let mut buf = req.pending_messages.lock().await;
if buf.len() < MAX_PENDING_MESSAGES {
buf.push(text.to_string());
buf.push(req.text.to_string());
}
eprintln!("[Weixin] context_token expired (ret=-2), buffered message");
return Ok(false);
}
return Err(ChatChannelError::SendFailed(format!(
"ret={ret}: {errmsg}"
)));
return Err(ChatChannelError::SendFailed(format!("ret={ret}: {errmsg}")));
}
}
}
@@ -377,10 +374,7 @@ impl WeixinBackend {
Ok(true)
}
async fn send_text(
&self,
text: &str,
) -> Result<SentMessageId, ChatChannelError> {
async fn send_text(&self, text: &str) -> Result<SentMessageId, ChatChannelError> {
// Extract context data under lock, then release
let (to_user_id, context_token, expired) = {
let guard = self.reply_context.lock().await;
@@ -418,17 +412,17 @@ impl WeixinBackend {
text.len()
);
Self::do_send(
&self.client,
&self.base_url,
&self.bot_token,
&self.wechat_uin,
&to_user_id,
&context_token,
Self::do_send(SendRequest {
client: &self.client,
base_url: &self.base_url,
bot_token: &self.bot_token,
wechat_uin: &self.wechat_uin,
to_user_id: &to_user_id,
context_token: &context_token,
text,
&self.reply_context,
&self.pending_messages,
)
reply_context: &self.reply_context,
pending_messages: &self.pending_messages,
})
.await?;
Ok(SentMessageId(String::new()))
@@ -478,10 +472,8 @@ impl ChatChannelBackend for WeixinBackend {
eprintln!("[Weixin] verify response status={status_code}, body={resp_text}");
let verify_result: serde_json::Value =
serde_json::from_str(&resp_text).map_err(|e| {
ChatChannelError::ConnectionFailed(format!("JSON parse failed: {e}"))
})?;
let verify_result: serde_json::Value = serde_json::from_str(&resp_text)
.map_err(|e| ChatChannelError::ConnectionFailed(format!("JSON parse failed: {e}")))?;
// iLink API auth failures come back as `{"errcode":-14,"errmsg":"session timeout"}`
// (no `ret` field). Treat any non-zero errcode as authentication failure.
@@ -606,8 +598,7 @@ impl ChatChannelBackend for WeixinBackend {
for msg in msgs {
// Only handle user messages (message_type=1),
// skip bot echo (message_type=2)
let msg_type =
msg.get("message_type").and_then(|v| v.as_i64());
let msg_type = msg.get("message_type").and_then(|v| v.as_i64());
if msg_type != Some(1) {
continue;
}
@@ -654,10 +645,8 @@ impl ChatChannelBackend for WeixinBackend {
if !from_user_id.is_empty() && !context_token.is_empty() {
let was_expired = {
let mut guard = reply_context.lock().await;
let was = guard
.as_ref()
.map(|c| c.expired)
.unwrap_or(false);
let was =
guard.as_ref().map(|c| c.expired).unwrap_or(false);
*guard = Some(WeixinReplyContext {
to_user_id: from_user_id.to_string(),
context_token: context_token.to_string(),
@@ -676,23 +665,22 @@ impl ChatChannelBackend for WeixinBackend {
buffered.len()
);
for pending_text in &buffered {
let ok = WeixinBackend::do_send(
&client,
&base_url,
&bot_token,
&wechat_uin,
from_user_id,
let ok = WeixinBackend::do_send(SendRequest {
client: &client,
base_url: &base_url,
bot_token: &bot_token,
wechat_uin: &wechat_uin,
to_user_id: from_user_id,
context_token,
pending_text,
&reply_context,
&pending_messages,
)
text: pending_text,
reply_context: &reply_context,
pending_messages: &pending_messages,
})
.await;
if let Err(e) = ok {
eprintln!("[Weixin] resend error: {e}");
// Re-buffer remaining on hard error
let mut buf =
pending_messages.lock().await;
let mut buf = pending_messages.lock().await;
if buf.len() < MAX_PENDING_MESSAGES {
buf.push(pending_text.clone());
}
@@ -724,15 +712,11 @@ impl ChatChannelBackend for WeixinBackend {
}
Err(e) => {
consecutive_errors += 1;
eprintln!(
"[Weixin] polling error ({consecutive_errors}): {e}"
);
eprintln!("[Weixin] polling error ({consecutive_errors}): {e}");
*status.lock().await = ChannelConnectionStatus::Error;
// Exponential backoff: 5s, 10s, 20s, capped at 30s
let delay = std::cmp::min(
5 * 2u64.saturating_pow(consecutive_errors - 1),
30,
);
let delay =
std::cmp::min(5 * 2u64.saturating_pow(consecutive_errors - 1), 30);
tokio::time::sleep(Duration::from_secs(delay)).await;
}
}
@@ -791,9 +775,8 @@ impl ChatChannelBackend for WeixinBackend {
eprintln!("[Weixin] test_connection: status={status_code}, body={resp_text}");
let resp_json: serde_json::Value = serde_json::from_str(&resp_text).map_err(|e| {
ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}"))
})?;
let resp_json: serde_json::Value = serde_json::from_str(&resp_text)
.map_err(|e| ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}")))?;
if !status_code.is_success() {
return Err(ChatChannelError::AuthenticationFailed(format!(
@@ -155,9 +155,16 @@ async fn dispatch_command(
guard.find_by_sender(channel_id, sender_id).is_some()
};
if has_session {
return session_commands::handle_followup(
db, text, channel_id, sender_id, conn_mgr, bridge, lang, prefix,
)
return session_commands::handle_followup(session_commands::FollowupRequest {
db,
text,
channel_id,
sender_id,
conn_mgr,
bridge,
lang,
prefix,
})
.await;
}
return command_handlers::handle_help(prefix, lang);
@@ -205,8 +212,7 @@ async fn dispatch_command(
.await
}
"cancel" => {
session_commands::handle_cancel(db, channel_id, sender_id, conn_mgr, bridge, lang)
.await
session_commands::handle_cancel(db, channel_id, sender_id, conn_mgr, bridge, lang).await
}
"approve" => {
let always = args.eq_ignore_ascii_case("always");
+4 -18
View File
@@ -6,11 +6,7 @@ use super::manager::ChatChannelManager;
use super::types::{MessageLevel, RichMessage};
use crate::db::entities::conversation;
pub async fn handle_search(
db: &DatabaseConnection,
keyword: &str,
lang: Lang,
) -> RichMessage {
pub async fn handle_search(db: &DatabaseConnection, keyword: &str, lang: Lang) -> RichMessage {
let matched = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.filter(conversation::Column::Title.contains(keyword))
@@ -40,10 +36,7 @@ pub async fn handle_search(
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
let agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!(
"#{} [{}] {} ({})\n",
conv.id, agent, title, time,
));
body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,));
}
RichMessage::info(body.trim_end()).with_title(i18n::search_results_count_title(
@@ -55,11 +48,7 @@ pub async fn handle_search(
pub async fn handle_today(db: &DatabaseConnection, lang: Lang) -> RichMessage {
let now = Utc::now();
let today_start = now
.date_naive()
.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc();
let 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())
@@ -99,10 +88,7 @@ pub async fn handle_today(db: &DatabaseConnection, lang: Lang) -> RichMessage {
let mut body = i18n::total_sessions(lang, rows.len() as u32);
body.push_str(&format!("\n\n{}", i18n::by_agent_label(lang)));
for (agent, count) in &by_agent {
body.push_str(&format!(
"\n {}",
i18n::agent_count(lang, agent, *count)
));
body.push_str(&format!("\n {}", i18n::agent_count(lang, agent, *count)));
}
if !titles.is_empty() {
@@ -9,7 +9,9 @@ use super::i18n::Lang;
use super::manager::ChatChannelManager;
use super::message_formatter;
use super::types::RichMessage;
use crate::db::service::{app_metadata_service, chat_channel_message_log_service, chat_channel_service};
use crate::db::service::{
app_metadata_service, chat_channel_message_log_service, chat_channel_service,
};
use crate::web::event_bridge::WebEventBroadcaster;
/// Minimum interval between pushes for the same event type per channel (debounce).
+56 -41
View File
@@ -340,7 +340,6 @@ pub fn search_results_count_title(lang: Lang, keyword: &str, count: usize) -> St
}
}
pub fn no_activity_today(lang: Lang) -> &'static str {
match lang {
Lang::ZhCn => "今日暂无编码活动",
@@ -667,36 +666,26 @@ pub fn search_usage(lang: Lang, prefix: &str) -> String {
pub fn unknown_command(lang: Lang, prefix: &str, command: &str) -> String {
match lang {
Lang::ZhCn => format!(
"未知命令: {prefix}{command}\n输入 {prefix}help 查看可用命令"
),
Lang::ZhTw => format!(
"未知命令: {prefix}{command}\n輸入 {prefix}help 查看可用命令"
),
Lang::Ja => format!(
"不明なコマンド: {prefix}{command}\n{prefix}help でヘルプを表示"
),
Lang::Ko => format!(
"알 수 없는 명령: {prefix}{command}\n{prefix}help 로 도움말 보기"
),
Lang::ZhCn => format!("未知命令: {prefix}{command}\n输入 {prefix}help 查看可用命令"),
Lang::ZhTw => format!("未知命令: {prefix}{command}\n輸入 {prefix}help 查看可用命令"),
Lang::Ja => format!("不明なコマンド: {prefix}{command}\n{prefix}help でヘルプを表示"),
Lang::Ko => format!("알 수 없는 명령: {prefix}{command}\n{prefix}help 로 도움말 보기"),
Lang::Es => format!(
"Comando desconocido: {prefix}{command}\nEscriba {prefix}help para ver los comandos"
),
Lang::De => format!(
"Unbekannter Befehl: {prefix}{command}\n{prefix}help für Hilfe eingeben"
),
Lang::Fr => format!(
"Commande inconnue : {prefix}{command}\nTapez {prefix}help pour l'aide"
),
Lang::Pt => format!(
"Comando desconhecido: {prefix}{command}\nDigite {prefix}help para ajuda"
),
Lang::Ar => format!(
"أمر غير معروف: {prefix}{command}\nاكتب {prefix}help لعرض المساعدة"
),
Lang::En => format!(
"Unknown command: {prefix}{command}\nType {prefix}help for available commands"
),
Lang::De => {
format!("Unbekannter Befehl: {prefix}{command}\n{prefix}help für Hilfe eingeben")
}
Lang::Fr => {
format!("Commande inconnue : {prefix}{command}\nTapez {prefix}help pour l'aide")
}
Lang::Pt => {
format!("Comando desconhecido: {prefix}{command}\nDigite {prefix}help para ajuda")
}
Lang::Ar => format!("أمر غير معروف: {prefix}{command}\nاكتب {prefix}help لعرض المساعدة"),
Lang::En => {
format!("Unknown command: {prefix}{command}\nType {prefix}help for available commands")
}
}
}
@@ -782,10 +771,14 @@ pub fn folder_index_out_of_range(lang: Lang, prefix: &str) -> String {
match lang {
Lang::ZhCn => format!("序号超出范围,请使用 {prefix}folder 查看列表。"),
Lang::ZhTw => format!("序號超出範圍,請使用 {prefix}folder 查看列表。"),
Lang::Ja => format!("インデックスが範囲外です。{prefix}folder でリストを確認してください。"),
Lang::Ja => {
format!("インデックスが範囲外です。{prefix}folder でリストを確認してください。")
}
Lang::Ko => format!("인덱스가 범위를 벗어났습니다. {prefix}folder로 목록을 확인하세요."),
Lang::Es => format!("Índice fuera de rango. Usa {prefix}folder para ver la lista."),
Lang::De => format!("Index außerhalb des Bereichs. {prefix}folder verwenden, um aufzulisten."),
Lang::De => {
format!("Index außerhalb des Bereichs. {prefix}folder verwenden, um aufzulisten.")
}
Lang::Fr => format!("Index hors limites. Utilisez {prefix}folder pour lister."),
Lang::Pt => format!("Índice fora de intervalo. Use {prefix}folder para listar."),
Lang::Ar => format!("الفهرس خارج النطاق. استخدم {prefix}folder لعرض القائمة."),
@@ -842,7 +835,9 @@ pub fn no_folder_selected(lang: Lang, prefix: &str) -> String {
match lang {
Lang::ZhCn => format!("未选择工作目录,请先使用 {prefix}folder 选择。"),
Lang::ZhTw => format!("未選擇工作目錄,請先使用 {prefix}folder 選擇。"),
Lang::Ja => format!("フォルダが選択されていません。先に {prefix}folder を使用してください。"),
Lang::Ja => {
format!("フォルダが選択されていません。先に {prefix}folder を使用してください。")
}
Lang::Ko => format!("폴더가 선택되지 않았습니다. 먼저 {prefix}folder를 사용하세요."),
Lang::Es => format!("Ninguna carpeta seleccionada. Usa {prefix}folder primero."),
Lang::De => format!("Kein Ordner ausgewählt. Zuerst {prefix}folder verwenden."),
@@ -873,12 +868,22 @@ pub fn agent_select_hint(lang: Lang, prefix: &str) -> String {
match lang {
Lang::ZhCn => format!("回复 {prefix}agent <数字> 或 {prefix}agent <名称> 选择。"),
Lang::ZhTw => format!("回覆 {prefix}agent <數字> 或 {prefix}agent <名稱> 選擇。"),
Lang::Ja => format!("{prefix}agent <番号> または {prefix}agent <名前> で選択してください。"),
Lang::Ja => {
format!("{prefix}agent <番号> または {prefix}agent <名前> で選択してください。")
}
Lang::Ko => format!("{prefix}agent <번호> 또는 {prefix}agent <이름>으로 선택하세요."),
Lang::Es => format!("Responde {prefix}agent <número> o {prefix}agent <nombre> para seleccionar."),
Lang::De => format!("Antworte {prefix}agent <Nummer> oder {prefix}agent <Name> zur Auswahl."),
Lang::Fr => format!("Répondez {prefix}agent <numéro> ou {prefix}agent <nom> pour sélectionner."),
Lang::Pt => format!("Responda {prefix}agent <número> ou {prefix}agent <nome> para selecionar."),
Lang::Es => {
format!("Responde {prefix}agent <número> o {prefix}agent <nombre> para seleccionar.")
}
Lang::De => {
format!("Antworte {prefix}agent <Nummer> oder {prefix}agent <Name> zur Auswahl.")
}
Lang::Fr => {
format!("Répondez {prefix}agent <numéro> ou {prefix}agent <nom> pour sélectionner.")
}
Lang::Pt => {
format!("Responda {prefix}agent <número> ou {prefix}agent <nome> para selecionar.")
}
Lang::Ar => format!("أجب بـ {prefix}agent <رقم> أو {prefix}agent <اسم> للاختيار."),
Lang::En => format!("Reply {prefix}agent <number> or {prefix}agent <name> to select."),
}
@@ -891,7 +896,9 @@ pub fn agent_index_out_of_range(lang: Lang, prefix: &str) -> String {
Lang::Ja => format!("インデックスが範囲外です。{prefix}agent でリストを確認してください。"),
Lang::Ko => format!("인덱스가 범위를 벗어났습니다. {prefix}agent로 목록을 확인하세요."),
Lang::Es => format!("Índice fuera de rango. Usa {prefix}agent para ver la lista."),
Lang::De => format!("Index außerhalb des Bereichs. {prefix}agent verwenden, um aufzulisten."),
Lang::De => {
format!("Index außerhalb des Bereichs. {prefix}agent verwenden, um aufzulisten.")
}
Lang::Fr => format!("Index hors limites. Utilisez {prefix}agent pour lister."),
Lang::Pt => format!("Índice fora de intervalo. Use {prefix}agent para listar."),
Lang::Ar => format!("الفهرس خارج النطاق. استخدم {prefix}agent لعرض القائمة."),
@@ -1299,7 +1306,9 @@ pub fn no_active_session_use_task(lang: Lang, prefix: &str) -> String {
match lang {
Lang::ZhCn => format!("没有活跃的会话,请使用 {prefix}task 开始新任务。"),
Lang::ZhTw => format!("沒有活躍的對話,請使用 {prefix}task 開始新任務。"),
Lang::Ja => format!("アクティブなセッションがありません。{prefix}task で開始してください。"),
Lang::Ja => {
format!("アクティブなセッションがありません。{prefix}task で開始してください。")
}
Lang::Ko => format!("활성 세션이 없습니다. {prefix}task로 시작하세요."),
Lang::Es => format!("No hay sesión activa. Usa {prefix}task para iniciar una."),
Lang::De => format!("Keine aktive Sitzung. {prefix}task zum Starten verwenden."),
@@ -1314,11 +1323,17 @@ pub fn session_connection_lost(lang: Lang, prefix: &str) -> String {
match lang {
Lang::ZhCn => format!("会话连接已断开,请使用 {prefix}task 开始新任务。"),
Lang::ZhTw => format!("對話連線已斷開,請使用 {prefix}task 開始新任務。"),
Lang::Ja => format!("セッション接続が切断されました。{prefix}task で新しく開始してください。"),
Lang::Ja => {
format!("セッション接続が切断されました。{prefix}task で新しく開始してください。")
}
Lang::Ko => format!("세션 연결이 끊어졌습니다. {prefix}task로 새로 시작하세요."),
Lang::Es => format!("Conexión de sesión perdida. Usa {prefix}task para iniciar una nueva."),
Lang::De => format!("Sitzungsverbindung verloren. {prefix}task für neue Sitzung verwenden."),
Lang::Fr => format!("Connexion de session perdue. Utilisez {prefix}task pour en démarrer une nouvelle."),
Lang::De => {
format!("Sitzungsverbindung verloren. {prefix}task für neue Sitzung verwenden.")
}
Lang::Fr => format!(
"Connexion de session perdue. Utilisez {prefix}task pour en démarrer une nouvelle."
),
Lang::Pt => format!("Conexão da sessão perdida. Use {prefix}task para iniciar uma nova."),
Lang::Ar => format!("انقطع اتصال الجلسة. استخدم {prefix}task لبدء جلسة جديدة."),
Lang::En => format!("Session connection lost. Use {prefix}task to start a new one."),
+29 -31
View File
@@ -268,28 +268,26 @@ impl ChatChannelManager {
}
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;
}
};
let channels = match crate::db::service::chat_channel_service::list_enabled(db_conn).await {
Ok(c) => c,
Err(e) => {
eprintln!("[ChatChannel] failed to load enabled channels: {e}");
return;
}
};
for ch in channels {
let channel_type: ChannelType = match serde_json::from_value(
serde_json::Value::String(ch.channel_type.clone()),
) {
Ok(t) => t,
Err(_) => {
eprintln!(
"[ChatChannel] unknown channel type '{}' for '{}' (id={}), skipping",
ch.channel_type, ch.name, ch.id
);
continue;
}
};
let channel_type: ChannelType =
match serde_json::from_value(serde_json::Value::String(ch.channel_type.clone())) {
Ok(t) => t,
Err(_) => {
eprintln!(
"[ChatChannel] unknown channel type '{}' for '{}' (id={}), skipping",
ch.channel_type, ch.name, ch.id
);
continue;
}
};
let config: serde_json::Value = match serde_json::from_str(&ch.config_json) {
Ok(v) => v,
@@ -313,17 +311,17 @@ impl ChatChannelManager {
}
};
let backend =
match super::backends::create_backend(ch.id, channel_type, &config, token) {
Ok(b) => b,
Err(e) => {
eprintln!(
"[ChatChannel] failed to create backend for '{}' (id={}): {e}",
ch.name, ch.id
);
continue;
}
};
let backend = match super::backends::create_backend(ch.id, channel_type, &config, token)
{
Ok(b) => b,
Err(e) => {
eprintln!(
"[ChatChannel] failed to create backend for '{}' (id={}): {e}",
ch.name, ch.id
);
continue;
}
};
if let Err(e) = self
.add_channel(ch.id, ch.name.clone(), channel_type, backend)
+5 -7
View File
@@ -8,7 +8,9 @@ use super::i18n::Lang;
use super::manager::ChatChannelManager;
use super::message_formatter::{self, DailyReportData};
use crate::db::entities::conversation;
use crate::db::service::{app_metadata_service, chat_channel_message_log_service, chat_channel_service};
use crate::db::service::{
app_metadata_service, chat_channel_message_log_service, chat_channel_service,
};
const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language";
/// Days to retain message logs before cleanup.
@@ -60,10 +62,7 @@ pub fn spawn_daily_report_scheduler(
continue;
}
let report_time = ch
.daily_report_time
.as_deref()
.unwrap_or("18:00");
let report_time = ch.daily_report_time.as_deref().unwrap_or("18:00");
if current_time != report_time {
continue;
@@ -124,8 +123,7 @@ async fn generate_daily_report(db: &DatabaseConnection) -> DailyReportData {
.await
.unwrap_or_default();
let mut by_agent: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
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();
+6 -6
View File
@@ -54,9 +54,9 @@ impl SessionBridge {
}
pub fn find_by_sender(&self, channel_id: i32, sender_id: &str) -> Option<&ActiveSession> {
self.sessions.values().find(|s| {
s.channel_id == channel_id && s.sender_id == sender_id
})
self.sessions
.values()
.find(|s| s.channel_id == channel_id && s.sender_id == sender_id)
}
pub fn find_by_sender_mut(
@@ -64,9 +64,9 @@ impl SessionBridge {
channel_id: i32,
sender_id: &str,
) -> Option<&mut ActiveSession> {
self.sessions.values_mut().find(|s| {
s.channel_id == channel_id && s.sender_id == sender_id
})
self.sessions
.values_mut()
.find(|s| s.channel_id == channel_id && s.sender_id == sender_id)
}
pub fn all_sessions(&self) -> impl Iterator<Item = &ActiveSession> {
+55 -71
View File
@@ -16,6 +16,17 @@ use crate::db::service::{conversation_service, folder_service, sender_context_se
use crate::models::agent::AgentType;
use crate::web::event_bridge::EventEmitter;
pub struct FollowupRequest<'a> {
pub db: &'a DatabaseConnection,
pub text: &'a str,
pub channel_id: i32,
pub sender_id: &'a str,
pub conn_mgr: &'a ConnectionManager,
pub bridge: &'a Arc<Mutex<SessionBridge>>,
pub lang: Lang,
pub prefix: &'a str,
}
// ── /folder ──
pub async fn handle_folder(
@@ -70,13 +81,7 @@ async fn list_folders(
.map(|id| id == f.id)
.unwrap_or(false);
let marker = if current { " [*]" } else { "" };
body.push_str(&format!(
"{}. {}{} ({})\n",
i + 1,
f.name,
marker,
f.path
));
body.push_str(&format!("{}. {}{} ({})\n", i + 1, f.name, marker, f.path));
}
body.push_str(&format!("\n{}", i18n::folder_select_hint(lang, prefix)));
@@ -107,8 +112,7 @@ async fn select_folder_by_index(
return RichMessage::info(i18n::folder_index_out_of_range(lang, prefix));
};
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(folder.id))
.await;
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(folder.id)).await;
RichMessage::info(format!("{} ({})", folder.name, folder.path))
.with_title(i18n::folder_selected_title(lang))
@@ -128,8 +132,7 @@ async fn select_folder_by_path(
}
};
let _ =
sender_context_service::update_folder(db, channel_id, sender_id, Some(entry.id)).await;
let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(entry.id)).await;
RichMessage::info(format!("{} ({})", entry.name, entry.path))
.with_title(i18n::folder_selected_title(lang))
@@ -317,10 +320,7 @@ pub async fn handle_task(
conversation::ConversationStatus::Cancelled,
)
.await;
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_start_agent_label(lang)
));
return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang)));
}
};
@@ -352,11 +352,8 @@ pub async fn handle_task(
)
.await;
RichMessage::info(format!(
"[{}] #{} @ {}",
agent_type, conv.id, folder.name,
))
.with_title(i18n::task_started_title(lang))
RichMessage::info(format!("[{}] #{} @ {}", agent_type, conv.id, folder.name,))
.with_title(i18n::task_started_title(lang))
}
// ── /sessions ──
@@ -401,16 +398,16 @@ pub async fn handle_sessions(
{
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_list_sessions_label(lang)
));
return RichMessage::error(format!("{}{e}", i18n::failed_to_list_sessions_label(lang)));
}
};
if convs.is_empty() {
return RichMessage::info(i18n::no_active_sessions_in_folder(lang))
.with_title(format!("{} - {}", i18n::sessions_title(lang), folder.name));
return RichMessage::info(i18n::no_active_sessions_in_folder(lang)).with_title(format!(
"{} - {}",
i18n::sessions_title(lang),
folder.name
));
}
let mut body = String::new();
@@ -433,8 +430,11 @@ pub async fn handle_sessions(
body.push_str(&format!("\n{}", i18n::sessions_resume_hint(lang, prefix)));
RichMessage::info(body.trim_end())
.with_title(format!("{} - {}", i18n::sessions_title(lang), folder.name))
RichMessage::info(body.trim_end()).with_title(format!(
"{} - {}",
i18n::sessions_title(lang),
folder.name
))
}
// ── /resume ──
@@ -491,10 +491,7 @@ pub async fn handle_resume(
{
Ok(id) => id,
Err(e) => {
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_start_agent_label(lang)
));
return RichMessage::error(format!("{}{e}", i18n::failed_to_start_agent_label(lang)));
}
};
@@ -579,8 +576,7 @@ pub async fn handle_cancel(
// Clear session from context
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
RichMessage::info(i18n::task_cancelled_body(lang))
.with_title(i18n::task_cancelled_title(lang))
RichMessage::info(i18n::task_cancelled_body(lang)).with_title(i18n::task_cancelled_title(lang))
}
// ── /approve, /deny ──
@@ -661,8 +657,7 @@ pub async fn handle_permission_response(
// Update auto_approve if requested
if always && approve {
let _ =
sender_context_service::update_auto_approve(db, channel_id, sender_id, true).await;
let _ = sender_context_service::update_auto_approve(db, channel_id, sender_id, true).await;
}
let action = if approve {
@@ -680,66 +675,58 @@ pub async fn handle_permission_response(
// ── follow-up (non-command text) ──
pub async fn handle_followup(
db: &DatabaseConnection,
text: &str,
channel_id: i32,
sender_id: &str,
conn_mgr: &ConnectionManager,
bridge: &Arc<Mutex<SessionBridge>>,
lang: Lang,
prefix: &str,
) -> RichMessage {
let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await {
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!("{}{e}", i18n::failed_to_load_context_label(lang)));
}
};
pub async fn handle_followup(req: FollowupRequest<'_>) -> RichMessage {
let ctx =
match sender_context_service::get_or_create(req.db, req.channel_id, req.sender_id).await {
Ok(c) => c,
Err(e) => {
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_load_context_label(req.lang)
));
}
};
let connection_id = match &ctx.current_connection_id {
Some(id) => id.clone(),
None => {
return RichMessage::info(i18n::no_active_session_use_task(lang, prefix));
return RichMessage::info(i18n::no_active_session_use_task(req.lang, req.prefix));
}
};
// Check connection exists in bridge
{
let bridge_guard = bridge.lock().await;
let bridge_guard = req.bridge.lock().await;
if bridge_guard.get(&connection_id).is_none() {
// Connection lost, clear context
drop(bridge_guard);
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
return RichMessage::info(i18n::session_connection_lost(lang, prefix));
let _ =
sender_context_service::clear_session(req.db, req.channel_id, req.sender_id).await;
return RichMessage::info(i18n::session_connection_lost(req.lang, req.prefix));
}
}
// Send prompt to agent
let blocks = vec![PromptInputBlock::Text {
text: text.to_string(),
text: req.text.to_string(),
}];
if let Err(e) = conn_mgr.send_prompt(&connection_id, blocks).await {
if let Err(e) = req.conn_mgr.send_prompt(&connection_id, blocks).await {
// Connection may have died
bridge.lock().await.remove(&connection_id);
let _ = sender_context_service::clear_session(db, channel_id, sender_id).await;
req.bridge.lock().await.remove(&connection_id);
let _ = sender_context_service::clear_session(req.db, req.channel_id, req.sender_id).await;
return RichMessage::error(format!(
"{}{e}",
i18n::failed_to_send_message_label(lang)
i18n::failed_to_send_message_label(req.lang)
));
}
RichMessage::info(i18n::message_sent(lang))
RichMessage::info(i18n::message_sent(req.lang))
}
// ── /resume (list recent) ──
async fn list_recent_sessions(
db: &DatabaseConnection,
lang: Lang,
prefix: &str,
) -> RichMessage {
async fn list_recent_sessions(db: &DatabaseConnection, lang: Lang, prefix: &str) -> RichMessage {
let recent = match conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.order_by_desc(conversation::Column::CreatedAt)
@@ -768,10 +755,7 @@ async fn list_recent_sessions(
let title = conv.title.as_deref().unwrap_or(i18n::untitled(lang));
let agent = &conv.agent_type;
let time = conv.created_at.format("%m-%d %H:%M");
body.push_str(&format!(
"#{} [{}] {} ({})\n",
conv.id, agent, title, time,
));
body.push_str(&format!("#{} [{}] {} ({})\n", conv.id, agent, title, time,));
}
body.push_str(&format!("\n{}", i18n::recent_resume_hint(lang, prefix)));
@@ -10,9 +10,7 @@ use super::session_bridge::{PendingPermission, SessionBridge};
use super::types::{MessageLevel, RichMessage};
use crate::acp::manager::ConnectionManager;
use crate::acp::types::PromptInputBlock;
use crate::db::service::{
app_metadata_service, conversation_service, sender_context_service,
};
use crate::db::service::{app_metadata_service, conversation_service, sender_context_service};
use crate::web::event_bridge::WebEventBroadcaster;
use super::manager::ChatChannelManager;
@@ -132,10 +130,7 @@ async fn handle_acp_event_payload(
}
"content_delta" => {
let text = payload
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("");
let text = payload.get("text").and_then(|v| v.as_str()).unwrap_or("");
// Collect flush info under the lock, then release before any IO.
let flush_info: Option<(i32, String, Option<String>)> = {
@@ -245,14 +240,11 @@ async fn handle_acp_event_payload(
let channel_id = session.channel_id;
let sender_id = session.sender_id.clone();
let auto_approve = sender_context_service::get_or_create(
db,
channel_id,
&sender_id,
)
.await
.map(|ctx| ctx.auto_approve)
.unwrap_or(false);
let auto_approve =
sender_context_service::get_or_create(db, channel_id, &sender_id)
.await
.map(|ctx| ctx.auto_approve)
.unwrap_or(false);
if auto_approve {
let option_id = options
@@ -286,8 +278,7 @@ async fn handle_acp_event_payload(
serde_json::Value::Null => None,
other => Some(other.to_string()),
});
let tool_desc =
format_tool_call_detail(tool_title, raw_input_str.as_deref());
let tool_desc = format_tool_call_detail(tool_title, raw_input_str.as_deref());
session.permission_pending = Some(PendingPermission {
request_id: request_id.to_string(),
@@ -407,16 +398,12 @@ async fn handle_acp_event_payload(
crate::db::entities::conversation::ConversationStatus::Cancelled,
)
.await;
let _ =
sender_context_service::clear_session(db, channel_id, &sender_id).await;
let _ = sender_context_service::clear_session(db, channel_id, &sender_id).await;
}
}
"status_changed" => {
let status = payload
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("");
let status = payload.get("status").and_then(|v| v.as_str()).unwrap_or("");
if status == "disconnected" || status == "error" {
let mut guard = bridge.lock().await;
@@ -425,8 +412,7 @@ async fn handle_acp_event_payload(
let sender_id = session.sender_id.clone();
drop(guard);
let _ =
sender_context_service::clear_session(db, channel_id, &sender_id).await;
let _ = sender_context_service::clear_session(db, channel_id, &sender_id).await;
}
}
}
@@ -653,7 +639,11 @@ fn format_tool_call_detail(title: &str, raw_input: Option<&str>) -> String {
if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) {
let path = obj.get("path").and_then(|v| v.as_str());
return if let Some(p) = path {
format!("Grep: \"{}\" in {}", truncate_str(pattern, 40), short_path(p))
format!(
"Grep: \"{}\" in {}",
truncate_str(pattern, 40),
short_path(p)
)
} else {
format!("Grep: \"{}\"", truncate_str(pattern, 60))
};
+2 -1
View File
@@ -147,7 +147,8 @@ impl InteractiveMessage {
.iter()
.map(|b| format!("[{}]", b.label))
.collect();
msg.body.push_str(&format!("\n\n{}", button_text.join(" ")));
msg.body
.push_str(&format!("\n\n{}", button_text.join(" ")));
}
msg
}