From 8050e30a55dd7a207f903c62b1fc3286e879bef3 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 2 Apr 2026 00:17:23 +0800 Subject: [PATCH] features: supports WeChat channel --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/chat_channel/backends/mod.rs | 16 + src-tauri/src/chat_channel/backends/weixin.rs | 653 ++++++++++++++++++ src-tauri/src/chat_channel/types.rs | 7 + src-tauri/src/commands/chat_channel.rs | 86 ++- src-tauri/src/lib.rs | 2 + src-tauri/src/web/handlers/chat_channel.rs | 27 + src-tauri/src/web/router.rs | 2 + .../settings/add-chat-channel-dialog.tsx | 76 +- src/components/settings/channel-list-tab.tsx | 44 +- .../settings/edit-chat-channel-dialog.tsx | 75 +- .../settings/weixin-qrcode-dialog.tsx | 206 ++++++ src/i18n/messages/ar.json | 7 + src/i18n/messages/de.json | 7 + src/i18n/messages/en.json | 7 + src/i18n/messages/es.json | 7 + src/i18n/messages/fr.json | 7 + src/i18n/messages/ja.json | 7 + src/i18n/messages/ko.json | 7 + src/i18n/messages/pt.json | 7 + src/i18n/messages/zh-CN.json | 7 + src/i18n/messages/zh-TW.json | 7 + src/lib/api.ts | 20 + src/lib/types.ts | 2 +- 25 files changed, 1223 insertions(+), 65 deletions(-) create mode 100644 src-tauri/src/chat_channel/backends/weixin.rs create mode 100644 src/components/settings/weixin-qrcode-dialog.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0c75eb4..59121a5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -866,6 +866,7 @@ dependencies = [ "notify", "portable-pty", "prost", + "rand 0.8.5", "regex", "reqwest 0.12.28", "sacp", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f3df4eb..089dccd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -81,6 +81,7 @@ tower-http = { version = "0.6", features = ["fs", "cors"] } tokio-tungstenite = { version = "0.26", features = ["native-tls"] } futures-util = "0.3" prost = "0.13" +rand = "0.8" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-window-state = { version = "2", optional = true } diff --git a/src-tauri/src/chat_channel/backends/mod.rs b/src-tauri/src/chat_channel/backends/mod.rs index bf23602..cbf0a72 100644 --- a/src-tauri/src/chat_channel/backends/mod.rs +++ b/src-tauri/src/chat_channel/backends/mod.rs @@ -1,5 +1,6 @@ pub mod lark; pub mod telegram; +pub mod weixin; use super::error::ChatChannelError; use super::traits::ChatChannelBackend; @@ -29,6 +30,21 @@ pub fn create_backend( cfg.chat_id, ))) } + ChannelType::Weixin => { + let cfg: WeixinConfig = serde_json::from_value(config.clone()).map_err(|e| { + ChatChannelError::ConfigurationInvalid(format!("Invalid Weixin config: {e}")) + })?; + if cfg.base_url.is_empty() { + return Err(ChatChannelError::ConfigurationInvalid( + "base_url is required".into(), + )); + } + Ok(Box::new(weixin::WeixinBackend::new( + channel_id, + token, + cfg.base_url, + ))) + } ChannelType::Lark => { let cfg: LarkConfig = serde_json::from_value(config.clone()).map_err(|e| { ChatChannelError::ConfigurationInvalid(format!("Invalid Lark config: {e}")) diff --git a/src-tauri/src/chat_channel/backends/weixin.rs b/src-tauri/src/chat_channel/backends/weixin.rs new file mode 100644 index 0000000..29d2431 --- /dev/null +++ b/src-tauri/src/chat_channel/backends/weixin.rs @@ -0,0 +1,653 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use rand::Rng; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, Mutex}; + +use crate::chat_channel::error::ChatChannelError; +use crate::chat_channel::traits::ChatChannelBackend; +use crate::chat_channel::types::*; + +const ILINK_BASE_URL: &str = "https://ilinkai.weixin.qq.com"; + +// ── QR code auth types (public, used by commands) ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WeixinQrcodeInfo { + pub qrcode_id: String, + pub qrcode_img_content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WeixinQrcodeStatus { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub bot_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, +} + +// ── QR code auth functions (called before backend exists) ── + +pub async fn weixin_get_qrcode() -> Result { + let client = reqwest::Client::new(); + let resp = client + .get(format!("{ILINK_BASE_URL}/ilink/bot/get_bot_qrcode")) + .query(&[("bot_type", "3")]) + .send() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(format!("QR code request failed: {e}")))?; + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(format!("QR code parse failed: {e}")))?; + + let qrcode_id = body + .get("qrcode") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let raw_img = body + .get("qrcode_img_content") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + if qrcode_id.is_empty() { + return Err(ChatChannelError::ConnectionFailed( + "Empty qrcode in response".into(), + )); + } + + // If the image content is a URL, fetch the actual image bytes and + // convert to a data-URI so the frontend can display it without CORS issues. + let qrcode_img_content = if raw_img.starts_with("http://") || raw_img.starts_with("https://") { + match fetch_image_as_data_uri(&client, &raw_img).await { + Ok(data_uri) => data_uri, + Err(e) => { + eprintln!("[Weixin] failed to proxy QR image: {e}, falling back to URL"); + raw_img + } + } + } else { + raw_img + }; + + Ok(WeixinQrcodeInfo { + qrcode_id, + qrcode_img_content, + }) +} + +/// Fetch an image from a URL and return it as a `data:;base64,...` string. +async fn fetch_image_as_data_uri( + client: &reqwest::Client, + url: &str, +) -> Result { + let resp = client + .get(url) + .header( + reqwest::header::USER_AGENT, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + ) + .header(reqwest::header::REFERER, ILINK_BASE_URL) + .send() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(format!("Image fetch failed: {e}")))?; + + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + + // If the server returned HTML instead of an image, bail out + if content_type.contains("text/html") || content_type.contains("text/plain") { + return Err(ChatChannelError::ConnectionFailed(format!( + "Expected image but got {content_type}" + ))); + } + + let bytes = resp + .bytes() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(format!("Image read failed: {e}")))?; + + let b64 = B64.encode(&bytes); + // Normalize content-type: strip parameters like charset + let mime = content_type.split(';').next().unwrap_or("image/png").trim(); + Ok(format!("data:{mime};base64,{b64}")) +} + +pub async fn weixin_check_qrcode( + qrcode: &str, +) -> Result { + let client = reqwest::Client::new(); + let resp = client + .get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status")) + .query(&[("qrcode", qrcode)]) + .send() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(format!("QR status request failed: {e}")))?; + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(format!("QR status parse failed: {e}")))?; + + let status = body + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("waiting") + .to_string(); + + let bot_token = body + .get("bot_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let base_url = body + .get("baseurl") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Ok(WeixinQrcodeStatus { + status, + bot_token, + base_url, + }) +} + +// ── Backend implementation ── + +struct WeixinReplyContext { + to_user_id: String, + context_token: String, + expired: bool, +} + +pub struct WeixinBackend { + bot_token: String, + base_url: String, + client: reqwest::Client, + status: Arc>, + channel_id: i32, + shutdown_tx: Arc>>>, + reply_context: Arc>>, + /// Messages that failed due to expired context_token, resend on next refresh. + pending_messages: Arc>>, +} + +impl WeixinBackend { + pub fn new(channel_id: i32, bot_token: String, base_url: String) -> Self { + Self { + bot_token, + base_url, + client: reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(45)) + .build() + .unwrap_or_default(), + status: Arc::new(Mutex::new(ChannelConnectionStatus::Disconnected)), + channel_id, + shutdown_tx: Arc::new(Mutex::new(None)), + reply_context: Arc::new(Mutex::new(None)), + pending_messages: Arc::new(Mutex::new(Vec::new())), + } + } + + fn build_headers(bot_token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + headers.insert( + "AuthorizationType", + HeaderValue::from_static("ilink_bot_token"), + ); + + let uin_raw = rand::thread_rng().gen::().to_string(); + let uin_b64 = B64.encode(uin_raw.as_bytes()); + if let Ok(val) = HeaderValue::from_str(&uin_b64) { + headers.insert("X-WECHAT-UIN", val); + } + + let bearer = format!("Bearer {bot_token}"); + if let Ok(val) = HeaderValue::from_str(&bearer) { + headers.insert("Authorization", val); + } + + headers + } + + async fn send_text( + &self, + text: &str, + ) -> Result { + // Extract context data under lock, then release + let (to_user_id, context_token, expired) = { + let guard = self.reply_context.lock().await; + let ctx = guard.as_ref().ok_or_else(|| { + ChatChannelError::SendFailed( + "No active WeChat conversation context. A user must message the bot first." + .into(), + ) + })?; + ( + ctx.to_user_id.clone(), + ctx.context_token.clone(), + ctx.expired, + ) + }; + + // If context is expired, buffer the message for resend on next refresh + if expired { + eprintln!( + "[Weixin] context expired, buffering message (len={})", + text.len() + ); + self.pending_messages.lock().await.push(text.to_string()); + return Ok(SentMessageId(String::new())); + } + + let client_id = format!("codeg-{}", uuid::Uuid::new_v4()); + let body = serde_json::json!({ + "msg": { + "from_user_id": "", + "to_user_id": to_user_id, + "client_id": client_id, + "message_type": 2, + "message_state": 2, + "context_token": context_token, + "item_list": [{ + "type": 1, + "text_item": { "text": text } + }] + }, + "base_info": { "channel_version": "1.0.2" } + }); + + let url = format!("{}/ilink/bot/sendmessage", self.base_url); + eprintln!( + "[Weixin] sendmessage to={to_user_id}, context_token_len={}, text_len={}", + context_token.len(), + text.len() + ); + + let resp = self + .client + .post(&url) + .headers(Self::build_headers(&self.bot_token)) + .json(&body) + .send() + .await + .map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; + + let status_code = resp.status(); + let resp_text = resp.text().await.unwrap_or_default(); + eprintln!("[Weixin] sendmessage response: status={status_code}, body={resp_text}"); + + if !status_code.is_success() { + return Err(ChatChannelError::SendFailed(format!( + "HTTP {status_code}: {resp_text}" + ))); + } + + // Check for ret errors in response (e.g. -2 = context expired) + if let Ok(resp_json) = serde_json::from_str::(&resp_text) { + if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) { + if ret != 0 { + let errmsg = resp_json + .get("errmsg") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + eprintln!("[Weixin] sendmessage ret={ret}, errmsg={errmsg}"); + + if ret == -2 { + // Context token expired — mark stale and buffer + if let Some(ref mut c) = *self.reply_context.lock().await { + c.expired = true; + } + self.pending_messages.lock().await.push(text.to_string()); + eprintln!("[Weixin] context_token expired (ret=-2), buffered message"); + return Ok(SentMessageId(String::new())); + } + + return Err(ChatChannelError::SendFailed(format!( + "ret={ret}: {errmsg}" + ))); + } + } + } + + Ok(SentMessageId(String::new())) + } +} + +#[async_trait] +impl ChatChannelBackend for WeixinBackend { + fn channel_type(&self) -> ChannelType { + ChannelType::Weixin + } + + async fn start( + &self, + command_tx: mpsc::Sender, + ) -> Result<(), ChatChannelError> { + *self.status.lock().await = ChannelConnectionStatus::Connecting; + + eprintln!( + "[Weixin] start: base_url={}, token_len={}", + self.base_url, + self.bot_token.len() + ); + + // Verify auth by doing a quick getupdates with empty cursor + let verify_body = serde_json::json!({ + "get_updates_buf": "", + "base_info": { "channel_version": "1.0.2" } + }); + let url = format!("{}/ilink/bot/getupdates", self.base_url); + eprintln!("[Weixin] verify POST {url}"); + + let resp = self + .client + .post(&url) + .headers(Self::build_headers(&self.bot_token)) + .json(&verify_body) + .send() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; + + let status_code = resp.status(); + let resp_text = resp + .text() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; + + eprintln!("[Weixin] verify response status={status_code}, body={resp_text}"); + + let verify_result: serde_json::Value = + serde_json::from_str(&resp_text).map_err(|e| { + ChatChannelError::ConnectionFailed(format!("JSON parse failed: {e}")) + })?; + + let ret = verify_result + .get("ret") + .and_then(|v| v.as_i64()) + .unwrap_or(-1); + + // The iLink API may omit the `ret` field or return non-zero on the first + // call. Always extract the cursor if present — it's needed for polling. + let initial_cursor = verify_result + .get("get_updates_buf") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if ret != 0 { + eprintln!( + "[Weixin] verify returned ret={ret}, but got cursor len={}", + initial_cursor.len() + ); + } + + *self.status.lock().await = ChannelConnectionStatus::Connected; + + // Start long-polling loop + let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); + *self.shutdown_tx.lock().await = Some(shutdown_tx); + + let client = self.client.clone(); + let bot_token = self.bot_token.clone(); + let base_url = self.base_url.clone(); + let channel_id = self.channel_id; + let status = self.status.clone(); + let reply_context = self.reply_context.clone(); + let pending_messages = self.pending_messages.clone(); + + tokio::spawn(async move { + let mut cursor = initial_cursor; + + loop { + if *shutdown_rx.borrow() { + break; + } + + let body = serde_json::json!({ + "get_updates_buf": cursor, + "base_info": { "channel_version": "1.0.2" } + }); + + let result = tokio::select! { + r = client + .post(format!("{base_url}/ilink/bot/getupdates")) + .headers(WeixinBackend::build_headers(&bot_token)) + .json(&body) + .send() => r, + _ = shutdown_rx.changed() => break, + }; + + match result { + Ok(resp) => { + // Recover from error state after successful poll + { + let mut s = status.lock().await; + if *s == ChannelConnectionStatus::Error { + *s = ChannelConnectionStatus::Connected; + } + } + + if let Ok(body) = resp.json::().await { + let ret = body.get("ret").and_then(|v| v.as_i64()); + + // Always update cursor if present + if let Some(new_cursor) = + body.get("get_updates_buf").and_then(|v| v.as_str()) + { + if !new_cursor.is_empty() { + cursor = new_cursor.to_string(); + } + } + + // If ret is explicitly non-zero (not just missing), log it + if let Some(r) = ret { + if r != 0 { + eprintln!("[Weixin] getupdates ret={r}"); + } + } + + // Process messages + if let Some(msgs) = body.get("msgs").and_then(|v| v.as_array()) { + if !msgs.is_empty() { + eprintln!("[Weixin] got {} message(s)", msgs.len()); + } + for msg in msgs { + // Only handle text messages (type 1 in item_list) + let text = msg + .get("item_list") + .and_then(|v| v.as_array()) + .and_then(|items| { + items.iter().find_map(|item| { + let t = + item.get("type").and_then(|v| v.as_i64())?; + if t == 1 { + item.pointer("/text_item/text") + .and_then(|v| v.as_str()) + } else { + None + } + }) + }); + + let text = match text { + Some(t) if !t.is_empty() => t, + _ => { + eprintln!("[Weixin] skipped non-text message"); + continue; + } + }; + + let from_user_id = msg + .get("from_user_id") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let context_token = msg + .get("context_token") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + // Store reply context for outbound messages + if !from_user_id.is_empty() && !context_token.is_empty() { + let was_expired = reply_context + .lock() + .await + .as_ref() + .map(|c| c.expired) + .unwrap_or(false); + + *reply_context.lock().await = Some(WeixinReplyContext { + to_user_id: from_user_id.to_string(), + context_token: context_token.to_string(), + expired: false, + }); + + // Resend buffered messages with fresh context + if was_expired { + let buffered: Vec = + pending_messages.lock().await.drain(..).collect(); + if !buffered.is_empty() { + eprintln!( + "[Weixin] context refreshed, resending {} buffered message(s)", + buffered.len() + ); + for pending_text in &buffered { + let cid = + format!("codeg-{}", uuid::Uuid::new_v4()); + let send_body = serde_json::json!({ + "msg": { + "from_user_id": "", + "to_user_id": from_user_id, + "client_id": cid, + "message_type": 2, + "message_state": 2, + "context_token": context_token, + "item_list": [{ + "type": 1, + "text_item": { "text": pending_text } + }] + }, + "base_info": { "channel_version": "1.0.2" } + }); + let _ = client + .post(format!( + "{base_url}/ilink/bot/sendmessage" + )) + .headers(WeixinBackend::build_headers( + &bot_token, + )) + .json(&send_body) + .send() + .await; + } + } + } + } + + eprintln!("[Weixin] dispatching: {text}"); + let send_result = command_tx + .send(IncomingCommand { + channel_id, + sender_id: from_user_id.to_string(), + command_text: text.to_string(), + metadata: msg.clone(), + }) + .await; + if let Err(e) = send_result { + eprintln!("[Weixin] command_tx.send failed: {e}"); + } + } + } + } else { + eprintln!("[Weixin] failed to parse response body"); + } + } + Err(e) => { + eprintln!("[Weixin] polling error: {e}"); + *status.lock().await = ChannelConnectionStatus::Error; + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } + *status.lock().await = ChannelConnectionStatus::Disconnected; + }); + + Ok(()) + } + + async fn stop(&self) -> Result<(), ChatChannelError> { + if let Some(tx) = self.shutdown_tx.lock().await.take() { + let _ = tx.send(true); + } + *self.status.lock().await = ChannelConnectionStatus::Disconnected; + Ok(()) + } + + async fn status(&self) -> ChannelConnectionStatus { + *self.status.lock().await + } + + async fn send_message(&self, text: &str) -> Result { + self.send_text(text).await + } + + async fn send_rich_message( + &self, + message: &RichMessage, + ) -> Result { + let plain_text = message.to_plain_text(); + self.send_text(&plain_text).await + } + + async fn test_connection(&self) -> Result<(), ChatChannelError> { + let body = serde_json::json!({ + "get_updates_buf": "", + "base_info": { "channel_version": "1.0.2" } + }); + + let url = format!("{}/ilink/bot/getupdates", self.base_url); + let resp = self + .client + .post(&url) + .headers(Self::build_headers(&self.bot_token)) + .json(&body) + .send() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; + + let status_code = resp.status(); + let resp_text = resp + .text() + .await + .map_err(|e| ChatChannelError::ConnectionFailed(e.to_string()))?; + + eprintln!("[Weixin] test_connection: status={status_code}, body={resp_text}"); + + // As long as we got a valid JSON response from the server, treat it as reachable. + // The iLink API may return ret != 0 on first empty-cursor call. + let _: serde_json::Value = serde_json::from_str(&resp_text).map_err(|e| { + ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}")) + })?; + + if !status_code.is_success() { + return Err(ChatChannelError::AuthenticationFailed(format!( + "HTTP {status_code}" + ))); + } + + Ok(()) + } +} diff --git a/src-tauri/src/chat_channel/types.rs b/src-tauri/src/chat_channel/types.rs index edc4fcf..32eaee8 100644 --- a/src-tauri/src/chat_channel/types.rs +++ b/src-tauri/src/chat_channel/types.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; pub enum ChannelType { Lark, Telegram, + Weixin, } // ── Per-channel strong typed configs ── @@ -20,11 +21,17 @@ pub struct LarkConfig { pub chat_id: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct WeixinConfig { + pub base_url: String, +} + impl std::fmt::Display for ChannelType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ChannelType::Lark => write!(f, "lark"), ChannelType::Telegram => write!(f, "telegram"), + ChannelType::Weixin => write!(f, "weixin"), } } } diff --git a/src-tauri/src/commands/chat_channel.rs b/src-tauri/src/commands/chat_channel.rs index b74147a..1306daa 100644 --- a/src-tauri/src/commands/chat_channel.rs +++ b/src-tauri/src/commands/chat_channel.rs @@ -1,4 +1,5 @@ use crate::app_error::AppCommandError; +use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatus}; use crate::chat_channel::manager::ChatChannelManager; use crate::chat_channel::types::ChannelType; use crate::db::service::{chat_channel_message_log_service, chat_channel_service}; @@ -112,8 +113,15 @@ pub async fn connect_chat_channel_core( .with_detail(e.to_string()) })?; - let token = crate::keyring_store::get_channel_token(id) - .ok_or_else(|| AppCommandError::configuration_missing("Token not set"))?; + let token = crate::keyring_store::get_channel_token(id).ok_or_else(|| { + eprintln!("[connect_chat_channel] channel {id}: Token not set in keyring"); + AppCommandError::configuration_missing("Token not set") + })?; + + eprintln!( + "[connect_chat_channel] channel {id}: creating {channel_type} backend, config={}", + model.config_json + ); let backend = crate::chat_channel::backends::create_backend(id, channel_type, &config, token) .map_err(AppCommandError::from)?; @@ -121,8 +129,12 @@ pub async fn connect_chat_channel_core( manager .add_channel(id, model.name, channel_type, backend) .await - .map_err(AppCommandError::from)?; + .map_err(|e| { + eprintln!("[connect_chat_channel] channel {id}: add_channel failed: {e}"); + AppCommandError::from(e) + })?; + eprintln!("[connect_chat_channel] channel {id}: connected successfully"); Ok(()) } @@ -325,6 +337,58 @@ pub async fn set_chat_event_filter_core( Ok(()) } +// --------------------------------------------------------------------------- +// WeChat QR code auth +// --------------------------------------------------------------------------- + +pub async fn weixin_get_qrcode_core() -> Result { + crate::chat_channel::backends::weixin::weixin_get_qrcode() + .await + .map_err(AppCommandError::from) +} + +pub async fn weixin_check_qrcode_core( + db: &AppDatabase, + channel_id: i32, + qrcode: &str, +) -> Result { + let result = crate::chat_channel::backends::weixin::weixin_check_qrcode(qrcode) + .await + .map_err(AppCommandError::from)?; + + // On confirmed: save token + update config with base_url + if result.status == "confirmed" { + eprintln!( + "[Weixin] QR confirmed for channel {channel_id}, bot_token={}, base_url={}", + result.bot_token.as_deref().map(|t| if t.len() > 8 { &t[..8] } else { t }).unwrap_or("None"), + result.base_url.as_deref().unwrap_or("None"), + ); + if let Some(ref token) = result.bot_token { + save_chat_channel_token_core(channel_id, token)?; + eprintln!("[Weixin] Token saved for channel {channel_id}"); + } else { + eprintln!("[Weixin] WARNING: No bot_token in confirmed response for channel {channel_id}"); + } + if let Some(ref base_url) = result.base_url { + let config_json = serde_json::json!({ "base_url": base_url }).to_string(); + update_chat_channel_core( + db, + channel_id, + None, + None, + Some(config_json), + None, + None, + None, + ) + .await?; + eprintln!("[Weixin] Config updated with base_url for channel {channel_id}"); + } + } + + Ok(result) +} + // --------------------------------------------------------------------------- // Tauri commands (use tauri::State for injection) // --------------------------------------------------------------------------- @@ -492,3 +556,19 @@ pub async fn set_chat_message_language( ) -> Result<(), AppCommandError> { set_chat_message_language_core(&db, language).await } + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn weixin_get_qrcode() -> Result { + weixin_get_qrcode_core().await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn weixin_check_qrcode( + db: tauri::State<'_, AppDatabase>, + channel_id: i32, + qrcode: String, +) -> Result { + weixin_check_qrcode_core(&db, channel_id, &qrcode).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 521c6d7..9d9faf7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -389,6 +389,8 @@ mod tauri_app { chat_channel_commands::set_chat_event_filter, chat_channel_commands::get_chat_message_language, chat_channel_commands::set_chat_message_language, + chat_channel_commands::weixin_get_qrcode, + chat_channel_commands::weixin_check_qrcode, web::start_web_server, web::stop_web_server, web::get_web_server_status, diff --git a/src-tauri/src/web/handlers/chat_channel.rs b/src-tauri/src/web/handlers/chat_channel.rs index b248bb1..a542a7f 100644 --- a/src-tauri/src/web/handlers/chat_channel.rs +++ b/src-tauri/src/web/handlers/chat_channel.rs @@ -6,6 +6,7 @@ use serde::Deserialize; use crate::app_error::AppCommandError; use crate::app_state::AppState; use crate::commands::chat_channel as cc_commands; +use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatus}; use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; // --------------------------------------------------------------------------- @@ -247,3 +248,29 @@ pub async fn set_chat_message_language( cc_commands::set_chat_message_language_core(&state.db, params.language).await?; Ok(Json(())) } + +// --------------------------------------------------------------------------- +// WeChat QR code auth +// --------------------------------------------------------------------------- + +pub async fn weixin_get_qrcode() -> Result, AppCommandError> { + let result = cc_commands::weixin_get_qrcode_core().await?; + Ok(Json(result)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WeixinCheckQrcodeParams { + pub channel_id: i32, + pub qrcode: String, +} + +pub async fn weixin_check_qrcode( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = + cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, ¶ms.qrcode) + .await?; + Ok(Json(result)) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 8bc2ff3..852e124 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -200,6 +200,8 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: .route("/set_chat_event_filter", post(handlers::chat_channel::set_chat_event_filter)) .route("/get_chat_message_language", post(handlers::chat_channel::get_chat_message_language)) .route("/set_chat_message_language", post(handlers::chat_channel::set_chat_message_language)) + .route("/weixin_get_qrcode", post(handlers::chat_channel::weixin_get_qrcode)) + .route("/weixin_check_qrcode", post(handlers::chat_channel::weixin_check_qrcode)) // ─── Terminal ─── .route("/terminal_spawn", post(handlers::terminal::terminal_spawn)) .route("/terminal_write", post(handlers::terminal::terminal_write)) diff --git a/src/components/settings/add-chat-channel-dialog.tsx b/src/components/settings/add-chat-channel-dialog.tsx index d4504e1..6be56c9 100644 --- a/src/components/settings/add-chat-channel-dialog.tsx +++ b/src/components/settings/add-chat-channel-dialog.tsx @@ -44,6 +44,7 @@ export function AddChatChannelDialog({ const [token, setToken] = useState("") const [chatId, setChatId] = useState("") const [appId, setAppId] = useState("") + const [baseUrl, setBaseUrl] = useState("https://ilinkai.weixin.qq.com") const [dailyReportEnabled, setDailyReportEnabled] = useState(false) const [dailyReportTime, setDailyReportTime] = useState("18:00") @@ -53,6 +54,7 @@ export function AddChatChannelDialog({ setToken("") setChatId("") setAppId("") + setBaseUrl("https://ilinkai.weixin.qq.com") setDailyReportEnabled(false) setDailyReportTime("18:00") setError(null) @@ -71,11 +73,11 @@ export function AddChatChannelDialog({ setError(t("nameRequired")) return } - if (!token.trim()) { + if (channelType !== "weixin" && !token.trim()) { setError(t("tokenRequired")) return } - if (!chatId.trim()) { + if (channelType !== "weixin" && !chatId.trim()) { setError(t("chatIdRequired")) return } @@ -84,9 +86,11 @@ export function AddChatChannelDialog({ setError(null) try { const configJson = - channelType === "lark" - ? JSON.stringify({ app_id: appId, chat_id: chatId }) - : JSON.stringify({ chat_id: chatId }) + channelType === "weixin" + ? JSON.stringify({ base_url: baseUrl }) + : channelType === "lark" + ? JSON.stringify({ app_id: appId, chat_id: chatId }) + : JSON.stringify({ chat_id: chatId }) const channel = await createChatChannel({ name: name.trim(), @@ -97,7 +101,9 @@ export function AddChatChannelDialog({ dailyReportTime: dailyReportEnabled ? dailyReportTime : null, }) - await saveChatChannelToken(channel.id, token.trim()) + if (channelType !== "weixin" && token.trim()) { + await saveChatChannelToken(channel.id, token.trim()) + } handleOpenChange(false) onChannelAdded() @@ -113,6 +119,7 @@ export function AddChatChannelDialog({ chatId, channelType, appId, + baseUrl, dailyReportEnabled, dailyReportTime, handleOpenChange, @@ -149,6 +156,7 @@ export function AddChatChannelDialog({ Telegram {t("lark")} + {t("weixin")} @@ -164,30 +172,40 @@ export function AddChatChannelDialog({ )} -
- - setToken(e.target.value)} - placeholder={ - channelType === "telegram" ? "123456:ABC-DEF..." : "xxxxx" - } - /> -
+ {channelType !== "weixin" && ( +
+ + setToken(e.target.value)} + placeholder={ + channelType === "telegram" ? "123456:ABC-DEF..." : "xxxxx" + } + /> +
+ )} -
- - setChatId(e.target.value)} - placeholder={ - channelType === "telegram" ? "-100123456789" : "oc_xxxxx" - } - /> -
+ {channelType !== "weixin" && ( +
+ + setChatId(e.target.value)} + placeholder={ + channelType === "telegram" ? "-100123456789" : "oc_xxxxx" + } + /> +
+ )} + + {channelType === "weixin" && ( +

+ {t("weixinScanDescription")} +

+ )}
diff --git a/src/components/settings/channel-list-tab.tsx b/src/components/settings/channel-list-tab.tsx index e61fbd2..36671bc 100644 --- a/src/components/settings/channel-list-tab.tsx +++ b/src/components/settings/channel-list-tab.tsx @@ -37,9 +37,14 @@ import { getChatChannelStatus, } from "@/lib/api" import { subscribe } from "@/lib/platform" -import type { ChatChannelInfo, ChannelStatusInfo } from "@/lib/types" +import type { + ChatChannelInfo, + ChannelStatusInfo, + ChannelType, +} from "@/lib/types" import { AddChatChannelDialog } from "./add-chat-channel-dialog" import { EditChatChannelDialog } from "./edit-chat-channel-dialog" +import { WeixinQrcodeDialog } from "./weixin-qrcode-dialog" export function ChannelListTab() { const t = useTranslations("ChatChannelSettings") @@ -50,6 +55,7 @@ export function ChannelListTab() { const [editTarget, setEditTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) const [actionLoading, setActionLoading] = useState(null) + const [qrcodeChannelId, setQrcodeChannelId] = useState(null) const loadChannels = useCallback(async () => { try { @@ -114,12 +120,35 @@ export function ChannelListTab() { ) const handleConnect = useCallback( - async (id: number) => { + async (id: number, channelType?: ChannelType) => { setActionLoading(id) try { await connectChatChannel(id) toast.success(t("connectSuccess")) await loadChannels() + } catch (err: unknown) { + if (channelType === "weixin") { + // No token or token expired — show QR code dialog + setQrcodeChannelId(id) + } else { + const msg = err instanceof Error ? err.message : String(err) + toast.error(t("connectFailed") + ": " + msg) + } + } finally { + setActionLoading(null) + } + }, + [loadChannels, t] + ) + + const handleWeixinAuthSuccess = useCallback( + async (channelId: number) => { + setQrcodeChannelId(null) + setActionLoading(channelId) + try { + await connectChatChannel(channelId) + toast.success(t("connectSuccess")) + await loadChannels() } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) toast.error(t("connectFailed") + ": " + msg) @@ -270,7 +299,7 @@ export function ChannelListTab() { size="sm" title={t("connect")} disabled={isLoading || !ch.enabled} - onClick={() => handleConnect(ch.id)} + onClick={() => handleConnect(ch.id, ch.channel_type)} > {isLoading ? ( @@ -327,6 +356,15 @@ export function ChannelListTab() { /> )} + {qrcodeChannelId !== null && ( + !open && setQrcodeChannelId(null)} + onAuthSuccess={handleWeixinAuthSuccess} + /> + )} + !open && setDeleteTarget(null)} diff --git a/src/components/settings/edit-chat-channel-dialog.tsx b/src/components/settings/edit-chat-channel-dialog.tsx index c00706a..ed39b1d 100644 --- a/src/components/settings/edit-chat-channel-dialog.tsx +++ b/src/components/settings/edit-chat-channel-dialog.tsx @@ -44,6 +44,7 @@ export function EditChatChannelDialog({ const [token, setToken] = useState("") const [chatId, setChatId] = useState(config.chat_id ?? "") const [appId, setAppId] = useState(config.app_id ?? "") + const [baseUrl] = useState(config.base_url ?? "") const [dailyReportEnabled, setDailyReportEnabled] = useState( channel.daily_report_enabled ) @@ -65,7 +66,7 @@ export function EditChatChannelDialog({ setError(t("nameRequired")) return } - if (!chatId.trim()) { + if (channel.channel_type !== "weixin" && !chatId.trim()) { setError(t("chatIdRequired")) return } @@ -74,9 +75,11 @@ export function EditChatChannelDialog({ setError(null) try { const configJson = - channel.channel_type === "lark" - ? JSON.stringify({ app_id: appId, chat_id: chatId }) - : JSON.stringify({ chat_id: chatId }) + channel.channel_type === "weixin" + ? JSON.stringify({ base_url: baseUrl }) + : channel.channel_type === "lark" + ? JSON.stringify({ app_id: appId, chat_id: chatId }) + : JSON.stringify({ chat_id: chatId }) await updateChatChannel({ id: channel.id, @@ -105,6 +108,7 @@ export function EditChatChannelDialog({ chatId, channel, appId, + baseUrl, dailyReportEnabled, dailyReportTime, onOpenChange, @@ -140,32 +144,45 @@ export function EditChatChannelDialog({
)} -
- - setToken(e.target.value)} - placeholder={ - hasToken ? t("tokenPlaceholderKeep") : t("tokenRequired") - } - /> -
+ {channel.channel_type !== "weixin" && ( +
+ + setToken(e.target.value)} + placeholder={ + hasToken ? t("tokenPlaceholderKeep") : t("tokenRequired") + } + /> +
+ )} -
- - setChatId(e.target.value)} - placeholder={ - channel.channel_type === "telegram" - ? "-100123456789" - : "oc_xxxxx" - } - /> -
+ {channel.channel_type !== "weixin" && ( +
+ + setChatId(e.target.value)} + placeholder={ + channel.channel_type === "telegram" + ? "-100123456789" + : "oc_xxxxx" + } + /> +
+ )} + + {channel.channel_type === "weixin" && baseUrl && ( +
+ + +
+ )}
diff --git a/src/components/settings/weixin-qrcode-dialog.tsx b/src/components/settings/weixin-qrcode-dialog.tsx new file mode 100644 index 0000000..74b47df --- /dev/null +++ b/src/components/settings/weixin-qrcode-dialog.tsx @@ -0,0 +1,206 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { ExternalLink, Loader2, RefreshCw } from "lucide-react" +import { useTranslations } from "next-intl" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { weixinGetQrcode, weixinCheckQrcode } from "@/lib/api" + +interface WeixinQrcodeDialogProps { + open: boolean + channelId: number + onOpenChange: (open: boolean) => void + onAuthSuccess: (channelId: number) => void +} + +function WeixinQrcodeContent({ + channelId, + onAuthSuccess, + onClose, +}: { + channelId: number + onAuthSuccess: (channelId: number) => void + onClose: () => void +}) { + const t = useTranslations("ChatChannelSettings") + const [qrcodeImg, setQrcodeImg] = useState(null) + const [qrcodeUrl, setQrcodeUrl] = useState(null) + const [imgFailed, setImgFailed] = useState(false) + const [qrcodeId, setQrcodeId] = useState(null) + const [status, setStatus] = useState<"loading" | "waiting" | "expired">( + "loading" + ) + const [error, setError] = useState(null) + const pollingRef = useRef | null>(null) + + const stopPolling = useCallback(() => { + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + }, []) + + const fetchQrcode = useCallback(async () => { + setStatus("loading") + setError(null) + setQrcodeImg(null) + setQrcodeUrl(null) + setImgFailed(false) + setQrcodeId(null) + stopPolling() + + try { + const result = await weixinGetQrcode() + setQrcodeId(result.qrcode_id) + + if (result.qrcode_img_content) { + const raw = result.qrcode_img_content + // Keep the original URL for fallback link + if (raw.startsWith("http")) { + setQrcodeUrl(raw) + } + const imgSrc = raw.startsWith("data:") + ? raw + : raw.startsWith("http") + ? raw + : `data:image/png;base64,${raw}` + setQrcodeImg(imgSrc) + } + + setStatus("waiting") + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + setError(msg) + setStatus("expired") + } + }, [stopPolling]) + + // Fetch QR code on mount + cleanup polling on unmount + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount + fetchQrcode() + return () => stopPolling() + }, [fetchQrcode, stopPolling]) + + // Start polling when we have a qrcodeId + useEffect(() => { + if (!qrcodeId || status !== "waiting") return + + pollingRef.current = setInterval(async () => { + try { + const result = await weixinCheckQrcode(channelId, qrcodeId) + if (result.status === "confirmed") { + stopPolling() + onAuthSuccess(channelId) + onClose() + } else if (result.status === "expired") { + stopPolling() + setStatus("expired") + } + } catch { + // Polling error — keep trying + } + }, 2000) + + return () => stopPolling() + }, [qrcodeId, status, channelId, stopPolling, onAuthSuccess, onClose]) + + return ( +
+

+ {t("weixinScanDescription")} +

+ + {status === "loading" && ( +
+ +
+ )} + + {status === "waiting" && qrcodeImg && ( + <> + {!imgFailed ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + WeChat QR Code setImgFailed(true)} + /> + + ) : qrcodeUrl ? ( + + + {t("weixinOpenQrcode")} + + ) : null} +

+ + {t("weixinWaitingScan")} +

+ + )} + + {status === "expired" && ( + <> +
+

+ {t("weixinQrcodeExpired")} +

+
+ + + )} + + {error && ( +
+ {error} +
+ )} +
+ ) +} + +export function WeixinQrcodeDialog({ + open, + channelId, + onOpenChange, + onAuthSuccess, +}: WeixinQrcodeDialogProps) { + const t = useTranslations("ChatChannelSettings") + const handleClose = useCallback(() => onOpenChange(false), [onOpenChange]) + + return ( + + + + {t("weixinScanTitle")} + + {open && ( + + )} + + + ) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 41ddcd8..e3c31ad 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1687,6 +1687,7 @@ "channelNamePlaceholder": "بوت Telegram الخاص بي", "channelType": "نوع القناة", "lark": "Lark (Feishu)", + "weixin": "WeChat", "dailyReport": "التقرير اليومي", "dailyReportTime": "وقت التقرير", "nameRequired": "اسم القناة مطلوب.", @@ -1713,6 +1714,12 @@ "editChannel": "تعديل القناة", "editSuccess": "تم تحديث القناة.", "tokenPlaceholderKeep": "اتركه فارغاً للاحتفاظ بالقيمة الحالية", + "weixinScanTitle": "مسح رمز QR", + "weixinScanDescription": "افتح WeChat وامسح رمز QR للاتصال.", + "weixinQrcodeExpired": "انتهت صلاحية رمز QR.", + "weixinRefreshQrcode": "تحديث", + "weixinWaitingScan": "في انتظار المسح...", + "weixinOpenQrcode": "فتح رمز QR في المتصفح", "connect": "اتصال", "disconnect": "قطع الاتصال", "test": "اختبار الاتصال", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 573e24b..871e582 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1687,6 +1687,7 @@ "channelNamePlaceholder": "Mein Telegram Bot", "channelType": "Kanaltyp", "lark": "Lark (Feishu)", + "weixin": "WeChat", "dailyReport": "Tagesbericht", "dailyReportTime": "Berichtszeit", "nameRequired": "Kanalname ist erforderlich.", @@ -1713,6 +1714,12 @@ "editChannel": "Kanal bearbeiten", "editSuccess": "Kanal aktualisiert.", "tokenPlaceholderKeep": "Leer lassen, um aktuellen Wert beizubehalten", + "weixinScanTitle": "QR-Code scannen", + "weixinScanDescription": "Öffnen Sie WeChat und scannen Sie den QR-Code, um eine Verbindung herzustellen.", + "weixinQrcodeExpired": "QR-Code abgelaufen.", + "weixinRefreshQrcode": "Aktualisieren", + "weixinWaitingScan": "Warten auf Scan...", + "weixinOpenQrcode": "QR-Code im Browser öffnen", "connect": "Verbinden", "disconnect": "Trennen", "test": "Verbindung testen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 846d68a..852f4de 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1687,6 +1687,7 @@ "channelNamePlaceholder": "My Telegram Bot", "channelType": "Channel Type", "lark": "Lark (Feishu)", + "weixin": "WeChat", "dailyReport": "Daily Report", "dailyReportTime": "Report Time", "nameRequired": "Channel name is required.", @@ -1713,6 +1714,12 @@ "editChannel": "Edit Channel", "editSuccess": "Channel updated.", "tokenPlaceholderKeep": "Leave blank to keep current", + "weixinScanTitle": "Scan QR Code", + "weixinScanDescription": "Open WeChat and scan the QR code to connect.", + "weixinQrcodeExpired": "QR code expired.", + "weixinRefreshQrcode": "Refresh", + "weixinWaitingScan": "Waiting for scan...", + "weixinOpenQrcode": "Open QR code in browser", "connect": "Connect", "disconnect": "Disconnect", "test": "Test Connection", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 83e5817..ddc8d71 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1687,6 +1687,7 @@ "channelNamePlaceholder": "Mi bot de Telegram", "channelType": "Tipo de canal", "lark": "Lark (Feishu)", + "weixin": "WeChat", "dailyReport": "Informe diario", "dailyReportTime": "Hora del informe", "nameRequired": "El nombre del canal es obligatorio.", @@ -1713,6 +1714,12 @@ "editChannel": "Editar canal", "editSuccess": "Canal actualizado.", "tokenPlaceholderKeep": "Dejar vacío para mantener actual", + "weixinScanTitle": "Escanear código QR", + "weixinScanDescription": "Abra WeChat y escanee el código QR para conectarse.", + "weixinQrcodeExpired": "El código QR ha expirado.", + "weixinRefreshQrcode": "Actualizar", + "weixinWaitingScan": "Esperando escaneo...", + "weixinOpenQrcode": "Abrir código QR en el navegador", "connect": "Conectar", "disconnect": "Desconectar", "test": "Probar conexión", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index aba2fea..d6c1541 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1687,6 +1687,7 @@ "channelNamePlaceholder": "Mon bot Telegram", "channelType": "Type de canal", "lark": "Lark (Feishu)", + "weixin": "WeChat", "dailyReport": "Rapport quotidien", "dailyReportTime": "Heure du rapport", "nameRequired": "Le nom du canal est requis.", @@ -1713,6 +1714,12 @@ "editChannel": "Modifier le canal", "editSuccess": "Canal mis à jour.", "tokenPlaceholderKeep": "Laisser vide pour conserver l'actuel", + "weixinScanTitle": "Scanner le QR code", + "weixinScanDescription": "Ouvrez WeChat et scannez le QR code pour vous connecter.", + "weixinQrcodeExpired": "QR code expiré.", + "weixinRefreshQrcode": "Actualiser", + "weixinWaitingScan": "En attente du scan...", + "weixinOpenQrcode": "Ouvrir le QR code dans le navigateur", "connect": "Connecter", "disconnect": "Déconnecter", "test": "Tester la connexion", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 10ac49c..92e5248 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1687,6 +1687,7 @@ "channelNamePlaceholder": "My Telegram Bot", "channelType": "チャンネルタイプ", "lark": "Lark(飛書)", + "weixin": "WeChat", "dailyReport": "デイリーレポート", "dailyReportTime": "送信時刻", "nameRequired": "チャンネル名を入力してください。", @@ -1713,6 +1714,12 @@ "editChannel": "チャンネルを編集", "editSuccess": "チャンネルを更新しました。", "tokenPlaceholderKeep": "空欄で現在の値を維持", + "weixinScanTitle": "QRコードをスキャン", + "weixinScanDescription": "WeChatを開いてQRコードをスキャンして接続してください。", + "weixinQrcodeExpired": "QRコードの有効期限が切れました。", + "weixinRefreshQrcode": "更新", + "weixinWaitingScan": "スキャン待ち...", + "weixinOpenQrcode": "ブラウザでQRコードを開く", "connect": "接続", "disconnect": "切断", "test": "接続テスト", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 3df750b..025adb5 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1687,6 +1687,7 @@ "channelNamePlaceholder": "내 Telegram 봇", "channelType": "채널 유형", "lark": "Lark (飛書)", + "weixin": "WeChat", "dailyReport": "일일 리포트", "dailyReportTime": "발송 시간", "nameRequired": "채널 이름을 입력하세요.", @@ -1713,6 +1714,12 @@ "editChannel": "채널 편집", "editSuccess": "채널이 업데이트되었습니다.", "tokenPlaceholderKeep": "비워두면 현재 값 유지", + "weixinScanTitle": "QR 코드 스캔", + "weixinScanDescription": "WeChat을 열고 QR 코드를 스캔하여 연결하세요.", + "weixinQrcodeExpired": "QR 코드가 만료되었습니다.", + "weixinRefreshQrcode": "새로고침", + "weixinWaitingScan": "스캔 대기 중...", + "weixinOpenQrcode": "브라우저에서 QR 코드 열기", "connect": "연결", "disconnect": "연결 해제", "test": "연결 테스트", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 923655d..329dbab 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1687,6 +1687,7 @@ "channelNamePlaceholder": "Meu bot do Telegram", "channelType": "Tipo de canal", "lark": "Lark (Feishu)", + "weixin": "WeChat", "dailyReport": "Relatório diário", "dailyReportTime": "Horário do relatório", "nameRequired": "O nome do canal é obrigatório.", @@ -1713,6 +1714,12 @@ "editChannel": "Editar canal", "editSuccess": "Canal atualizado.", "tokenPlaceholderKeep": "Deixar em branco para manter atual", + "weixinScanTitle": "Escanear código QR", + "weixinScanDescription": "Abra o WeChat e escaneie o código QR para conectar.", + "weixinQrcodeExpired": "Código QR expirado.", + "weixinRefreshQrcode": "Atualizar", + "weixinWaitingScan": "Aguardando escaneamento...", + "weixinOpenQrcode": "Abrir código QR no navegador", "connect": "Conectar", "disconnect": "Desconectar", "test": "Testar conexão", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 40b0d4c..0553722 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1687,6 +1687,7 @@ "channelNamePlaceholder": "我的 Telegram 机器人", "channelType": "渠道类型", "lark": "飞书", + "weixin": "微信", "dailyReport": "每日报告", "dailyReportTime": "推送时间", "nameRequired": "请输入渠道名称。", @@ -1713,6 +1714,12 @@ "editChannel": "编辑渠道", "editSuccess": "渠道已更新。", "tokenPlaceholderKeep": "留空保持不变", + "weixinScanTitle": "扫码登录", + "weixinScanDescription": "打开微信扫描二维码以连接。", + "weixinQrcodeExpired": "二维码已过期。", + "weixinRefreshQrcode": "刷新二维码", + "weixinWaitingScan": "等待扫码...", + "weixinOpenQrcode": "在浏览器中打开二维码", "connect": "连接", "disconnect": "断开", "test": "测试连接", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 52cbf7b..63623c6 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1687,6 +1687,7 @@ "channelNamePlaceholder": "我的 Telegram 機器人", "channelType": "頻道類型", "lark": "飛書", + "weixin": "微信", "dailyReport": "每日報告", "dailyReportTime": "推送時間", "nameRequired": "請輸入頻道名稱。", @@ -1713,6 +1714,12 @@ "editChannel": "編輯頻道", "editSuccess": "頻道已更新。", "tokenPlaceholderKeep": "留空保持不變", + "weixinScanTitle": "掃碼登入", + "weixinScanDescription": "打開微信掃描二維碼以連接。", + "weixinQrcodeExpired": "二維碼已過期。", + "weixinRefreshQrcode": "重新整理", + "weixinWaitingScan": "等待掃碼...", + "weixinOpenQrcode": "在瀏覽器中打開二維碼", "connect": "連線", "disconnect": "斷開", "test": "測試連線", diff --git a/src/lib/api.ts b/src/lib/api.ts index ed4038e..0c91d86 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1440,3 +1440,23 @@ export async function getChatMessageLanguage(): Promise { export async function setChatMessageLanguage(language: string): Promise { return getTransport().call("set_chat_message_language", { language }) } + +// ─── WeChat QR Code Auth ─── + +export async function weixinGetQrcode(): Promise<{ + qrcode_id: string + qrcode_img_content: string +}> { + return getTransport().call("weixin_get_qrcode") +} + +export async function weixinCheckQrcode( + channelId: number, + qrcode: string +): Promise<{ + status: string + bot_token?: string + base_url?: string +}> { + return getTransport().call("weixin_check_qrcode", { channelId, qrcode }) +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 20b8b8f..6488eeb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -850,7 +850,7 @@ export interface PreflightResult { // ─── Chat Channels ─── -export type ChannelType = "lark" | "telegram" +export type ChannelType = "lark" | "telegram" | "weixin" export type ChannelConnectionStatus = | "connected"