optimize: WeChat QR code auth flow and channel reliability

- Generate QR code server-side when iLink API returns SPA page URL
  (added qrcode + image crates for PNG generation)
- Strip bot_token from frontend response (new WeixinQrcodeStatusPublic type)
- Add request timeouts and shared HTTP client for QR code endpoints
- Fix TOCTOU race on reply_context double-lock (single lock scope)
- Extract do_send() helper to deduplicate sendmessage logic; resend now
  checks ret field for context expiry instead of HTTP status only
- Cap pending_messages buffer at 50 to prevent unbounded memory growth
- Generate stable X-WECHAT-UIN per backend instance instead of per request
- Extract ILINK_CHANNEL_VERSION constant (was hardcoded in 4 places)
- Add 5-minute client-side QR expiry timeout in frontend dialog
- Track consecutive polling errors and show warning after 3 failures
- Stabilise onAuthSuccess/onClose callback refs to prevent polling restarts
- Replace dead i18n key weixinOpenQrcode with weixinPollError

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-02 11:22:51 +08:00
parent 0ef36ee918
commit d0e0aad525
17 changed files with 379 additions and 207 deletions

67
src-tauri/Cargo.lock generated
View File

@@ -615,6 +615,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -860,12 +866,14 @@ dependencies = [
"flate2", "flate2",
"futures", "futures",
"futures-util", "futures-util",
"image",
"keyring", "keyring",
"kill_tree", "kill_tree",
"mac-notification-sys", "mac-notification-sys",
"notify", "notify",
"portable-pty", "portable-pty",
"prost", "prost",
"qrcode",
"rand 0.8.5", "rand 0.8.5",
"regex", "regex",
"reqwest 0.12.28", "reqwest 0.12.28",
@@ -2465,7 +2473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"png", "png 0.17.16",
] ]
[[package]] [[package]]
@@ -2576,6 +2584,19 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -3196,6 +3217,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "muda" name = "muda"
version = "0.17.1" version = "0.17.1"
@@ -3211,7 +3242,7 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation",
"once_cell", "once_cell",
"png", "png 0.17.16",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
"windows-sys 0.60.2", "windows-sys 0.60.2",
@@ -4119,6 +4150,19 @@ dependencies = [
"miniz_oxide", "miniz_oxide",
] ]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.10.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -4340,6 +4384,21 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "pxfm"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
dependencies = [
"image",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.5" version = "0.37.5"
@@ -6263,7 +6322,7 @@ dependencies = [
"ico", "ico",
"json-patch", "json-patch",
"plist", "plist",
"png", "png 0.17.16",
"proc-macro2", "proc-macro2",
"quote", "quote",
"semver", "semver",
@@ -7014,7 +7073,7 @@ dependencies = [
"objc2-core-graphics", "objc2-core-graphics",
"objc2-foundation", "objc2-foundation",
"once_cell", "once_cell",
"png", "png 0.17.16",
"serde", "serde",
"thiserror 2.0.18", "thiserror 2.0.18",
"windows-sys 0.60.2", "windows-sys 0.60.2",

View File

@@ -82,6 +82,8 @@ tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
futures-util = "0.3" futures-util = "0.3"
prost = "0.13" prost = "0.13"
rand = "0.8" rand = "0.8"
qrcode = "0.14"
image = { version = "0.25", default-features = false, features = ["png"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-window-state = { version = "2", optional = true } tauri-plugin-window-state = { version = "2", optional = true }

View File

@@ -13,6 +13,24 @@ use crate::chat_channel::traits::ChatChannelBackend;
use crate::chat_channel::types::*; use crate::chat_channel::types::*;
const ILINK_BASE_URL: &str = "https://ilinkai.weixin.qq.com"; const ILINK_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
const ILINK_CHANNEL_VERSION: &str = "1.0.2";
/// Maximum number of messages buffered while context_token is expired.
const MAX_PENDING_MESSAGES: usize = 50;
/// Shared HTTP client for QR code auth requests (avoids re-creating TLS state).
fn qr_client() -> reqwest::Client {
use std::sync::OnceLock;
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT
.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.build()
.unwrap_or_default()
})
.clone()
}
// ── QR code auth types (public, used by commands) ── // ── QR code auth types (public, used by commands) ──
@@ -25,16 +43,24 @@ pub struct WeixinQrcodeInfo {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeixinQrcodeStatus { pub struct WeixinQrcodeStatus {
pub status: String, pub status: String,
/// bot_token and base_url are consumed by the _core command layer and
/// stripped before the response reaches the frontend.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub bot_token: Option<String>, pub bot_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>, pub base_url: Option<String>,
} }
/// Frontend-safe subset of [`WeixinQrcodeStatus`] — no credentials.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeixinQrcodeStatusPublic {
pub status: String,
}
// ── QR code auth functions (called before backend exists) ── // ── QR code auth functions (called before backend exists) ──
pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, ChatChannelError> { pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, ChatChannelError> {
let client = reqwest::Client::new(); let client = qr_client();
let resp = client let resp = client
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_bot_qrcode")) .get(format!("{ILINK_BASE_URL}/ilink/bot/get_bot_qrcode"))
.query(&[("bot_type", "3")]) .query(&[("bot_type", "3")])
@@ -64,14 +90,15 @@ pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, ChatChannelError> {
)); ));
} }
// If the image content is a URL, fetch the actual image bytes and // If the image content is a URL, try to fetch the actual image bytes.
// convert to a data-URI so the frontend can display it without CORS issues. // If the URL points to an HTML SPA (which renders the QR code via JS),
// generate the QR code ourselves — the SPA simply encodes the page URL.
let qrcode_img_content = if raw_img.starts_with("http://") || raw_img.starts_with("https://") { 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 { match fetch_image_as_data_uri(&client, &raw_img).await {
Ok(data_uri) => data_uri, Ok(data_uri) => data_uri,
Err(e) => { Err(_) => {
eprintln!("[Weixin] failed to proxy QR image: {e}, falling back to URL"); eprintln!("[Weixin] URL is an SPA page, generating QR code from URL");
raw_img generate_qrcode_data_uri(&raw_img)?
} }
} }
} else { } else {
@@ -85,6 +112,9 @@ pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, ChatChannelError> {
} }
/// Fetch an image from a URL and return it as a `data:<mime>;base64,...` string. /// Fetch an image from a URL and return it as a `data:<mime>;base64,...` string.
///
/// Returns an error if the URL points to an HTML page (SPA) rather than a
/// raw image — the caller will generate a QR code from the URL instead.
async fn fetch_image_as_data_uri( async fn fetch_image_as_data_uri(
client: &reqwest::Client, client: &reqwest::Client,
url: &str, url: &str,
@@ -107,11 +137,10 @@ async fn fetch_image_as_data_uri(
.unwrap_or("image/png") .unwrap_or("image/png")
.to_string(); .to_string();
// If the server returned HTML instead of an image, bail out
if content_type.contains("text/html") || content_type.contains("text/plain") { if content_type.contains("text/html") || content_type.contains("text/plain") {
return Err(ChatChannelError::ConnectionFailed(format!( return Err(ChatChannelError::ConnectionFailed(
"Expected image but got {content_type}" "QR code URL is an SPA page".into(),
))); ));
} }
let bytes = resp let bytes = resp
@@ -119,16 +148,48 @@ async fn fetch_image_as_data_uri(
.await .await
.map_err(|e| ChatChannelError::ConnectionFailed(format!("Image read failed: {e}")))?; .map_err(|e| ChatChannelError::ConnectionFailed(format!("Image read failed: {e}")))?;
if bytes.is_empty() {
return Err(ChatChannelError::ConnectionFailed(
"Empty image response".into(),
));
}
let b64 = B64.encode(&bytes); let b64 = B64.encode(&bytes);
// Normalize content-type: strip parameters like charset
let mime = content_type.split(';').next().unwrap_or("image/png").trim(); let mime = content_type.split(';').next().unwrap_or("image/png").trim();
Ok(format!("data:{mime};base64,{b64}")) Ok(format!("data:{mime};base64,{b64}"))
} }
/// Generate a QR code image encoding the given text and return as a PNG data URI.
///
/// The iLink QR page is a SPA that renders `window.location.href` as a QR code.
/// We replicate that logic server-side so the frontend can display it directly.
fn generate_qrcode_data_uri(content: &str) -> Result<String, ChatChannelError> {
use image::{codecs::png::PngEncoder, ImageEncoder, Luma};
use qrcode::QrCode;
let code = QrCode::new(content.as_bytes()).map_err(|e| {
ChatChannelError::ConnectionFailed(format!("QR code generation failed: {e}"))
})?;
let img = code
.render::<Luma<u8>>()
.quiet_zone(true)
.min_dimensions(250, 250)
.build();
let (w, h) = (img.width(), img.height());
let mut png_buf: Vec<u8> = Vec::new();
PngEncoder::new(&mut png_buf)
.write_image(img.as_raw(), w, h, image::ExtendedColorType::L8)
.map_err(|e| ChatChannelError::ConnectionFailed(format!("PNG encoding failed: {e}")))?;
let b64 = B64.encode(&png_buf);
Ok(format!("data:image/png;base64,{b64}"))
}
pub async fn weixin_check_qrcode( pub async fn weixin_check_qrcode(
qrcode: &str, qrcode: &str,
) -> Result<WeixinQrcodeStatus, ChatChannelError> { ) -> Result<WeixinQrcodeStatus, ChatChannelError> {
let client = reqwest::Client::new(); let client = qr_client();
let resp = client let resp = client
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status")) .get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status"))
.query(&[("qrcode", qrcode)]) .query(&[("qrcode", qrcode)])
@@ -181,10 +242,15 @@ pub struct WeixinBackend {
reply_context: Arc<Mutex<Option<WeixinReplyContext>>>, reply_context: Arc<Mutex<Option<WeixinReplyContext>>>,
/// Messages that failed due to expired context_token, resend on next refresh. /// Messages that failed due to expired context_token, resend on next refresh.
pending_messages: Arc<Mutex<Vec<String>>>, pending_messages: Arc<Mutex<Vec<String>>>,
/// Stable X-WECHAT-UIN value for this backend instance.
wechat_uin: String,
} }
impl WeixinBackend { impl WeixinBackend {
pub fn new(channel_id: i32, bot_token: String, base_url: String) -> Self { pub fn new(channel_id: i32, bot_token: String, base_url: String) -> Self {
let uin_raw = rand::thread_rng().gen::<u32>().to_string();
let wechat_uin = B64.encode(uin_raw.as_bytes());
Self { Self {
bot_token, bot_token,
base_url, base_url,
@@ -198,10 +264,11 @@ impl WeixinBackend {
shutdown_tx: Arc::new(Mutex::new(None)), shutdown_tx: Arc::new(Mutex::new(None)),
reply_context: Arc::new(Mutex::new(None)), reply_context: Arc::new(Mutex::new(None)),
pending_messages: Arc::new(Mutex::new(Vec::new())), pending_messages: Arc::new(Mutex::new(Vec::new())),
wechat_uin,
} }
} }
fn build_headers(bot_token: &str) -> HeaderMap { fn build_headers(bot_token: &str, wechat_uin: &str) -> HeaderMap {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("Content-Type", HeaderValue::from_static("application/json")); headers.insert("Content-Type", HeaderValue::from_static("application/json"));
headers.insert( headers.insert(
@@ -209,9 +276,7 @@ impl WeixinBackend {
HeaderValue::from_static("ilink_bot_token"), HeaderValue::from_static("ilink_bot_token"),
); );
let uin_raw = rand::thread_rng().gen::<u32>().to_string(); if let Ok(val) = HeaderValue::from_str(wechat_uin) {
let uin_b64 = B64.encode(uin_raw.as_bytes());
if let Ok(val) = HeaderValue::from_str(&uin_b64) {
headers.insert("X-WECHAT-UIN", val); headers.insert("X-WECHAT-UIN", val);
} }
@@ -223,6 +288,95 @@ impl WeixinBackend {
headers headers
} }
/// Build the JSON body for the iLink sendmessage API.
fn build_send_body(
to_user_id: &str,
context_token: &str,
text: &str,
) -> serde_json::Value {
serde_json::json!({
"msg": {
"from_user_id": "",
"to_user_id": to_user_id,
"client_id": format!("codeg-{}", uuid::Uuid::new_v4()),
"message_type": 2,
"message_state": 2,
"context_token": context_token,
"item_list": [{
"type": 1,
"text_item": { "text": text }
}]
},
"base_info": { "channel_version": ILINK_CHANNEL_VERSION }
})
}
/// Send a message via the iLink API and handle the response.
/// Returns `Ok(true)` if sent, `Ok(false)` if buffered due to expired context.
async fn do_send(
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");
let resp = client
.post(&url)
.headers(Self::build_headers(bot_token, wechat_uin))
.json(&body)
.send()
.await
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
let status_code = resp.status();
let resp_text = resp.text().await.unwrap_or_default();
if !status_code.is_success() {
return Err(ChatChannelError::SendFailed(format!(
"HTTP {status_code}: {resp_text}"
)));
}
// Check for ret errors in response (e.g. -2 = context expired)
if let Ok(resp_json) = serde_json::from_str::<serde_json::Value>(&resp_text) {
if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) {
if ret != 0 {
let errmsg = resp_json
.get("errmsg")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
eprintln!("[Weixin] sendmessage ret={ret}, errmsg={errmsg}");
if ret == -2 {
// Context token expired — mark stale and buffer
if let Some(ref mut c) = *reply_context.lock().await {
c.expired = true;
}
let mut buf = pending_messages.lock().await;
if buf.len() < MAX_PENDING_MESSAGES {
buf.push(text.to_string());
}
eprintln!("[Weixin] context_token expired (ret=-2), buffered message");
return Ok(false);
}
return Err(ChatChannelError::SendFailed(format!(
"ret={ret}: {errmsg}"
)));
}
}
}
Ok(true)
}
async fn send_text( async fn send_text(
&self, &self,
text: &str, text: &str,
@@ -249,79 +403,33 @@ impl WeixinBackend {
"[Weixin] context expired, buffering message (len={})", "[Weixin] context expired, buffering message (len={})",
text.len() text.len()
); );
self.pending_messages.lock().await.push(text.to_string()); let mut buf = self.pending_messages.lock().await;
if buf.len() < MAX_PENDING_MESSAGES {
buf.push(text.to_string());
} else {
eprintln!("[Weixin] pending buffer full, dropping message");
}
return Ok(SentMessageId(String::new())); 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!( eprintln!(
"[Weixin] sendmessage to={to_user_id}, context_token_len={}, text_len={}", "[Weixin] sendmessage to={to_user_id}, context_token_len={}, text_len={}",
context_token.len(), context_token.len(),
text.len() text.len()
); );
let resp = self Self::do_send(
.client &self.client,
.post(&url) &self.base_url,
.headers(Self::build_headers(&self.bot_token)) &self.bot_token,
.json(&body) &self.wechat_uin,
.send() &to_user_id,
.await &context_token,
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?; text,
&self.reply_context,
let status_code = resp.status(); &self.pending_messages,
let resp_text = resp.text().await.unwrap_or_default(); )
eprintln!("[Weixin] sendmessage response: status={status_code}, body={resp_text}"); .await?;
if !status_code.is_success() {
return Err(ChatChannelError::SendFailed(format!(
"HTTP {status_code}: {resp_text}"
)));
}
// Check for ret errors in response (e.g. -2 = context expired)
if let Ok(resp_json) = serde_json::from_str::<serde_json::Value>(&resp_text) {
if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) {
if ret != 0 {
let errmsg = resp_json
.get("errmsg")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
eprintln!("[Weixin] sendmessage ret={ret}, errmsg={errmsg}");
if ret == -2 {
// Context token expired — mark stale and buffer
if let Some(ref mut c) = *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())) Ok(SentMessageId(String::new()))
} }
@@ -348,7 +456,7 @@ impl ChatChannelBackend for WeixinBackend {
// Verify auth by doing a quick getupdates with empty cursor // Verify auth by doing a quick getupdates with empty cursor
let verify_body = serde_json::json!({ let verify_body = serde_json::json!({
"get_updates_buf": "", "get_updates_buf": "",
"base_info": { "channel_version": "1.0.2" } "base_info": { "channel_version": ILINK_CHANNEL_VERSION }
}); });
let url = format!("{}/ilink/bot/getupdates", self.base_url); let url = format!("{}/ilink/bot/getupdates", self.base_url);
eprintln!("[Weixin] verify POST {url}"); eprintln!("[Weixin] verify POST {url}");
@@ -356,7 +464,7 @@ impl ChatChannelBackend for WeixinBackend {
let resp = self let resp = self
.client .client
.post(&url) .post(&url)
.headers(Self::build_headers(&self.bot_token)) .headers(Self::build_headers(&self.bot_token, &self.wechat_uin))
.json(&verify_body) .json(&verify_body)
.send() .send()
.await .await
@@ -380,6 +488,13 @@ impl ChatChannelBackend for WeixinBackend {
.and_then(|v| v.as_i64()) .and_then(|v| v.as_i64())
.unwrap_or(-1); .unwrap_or(-1);
// Check for known auth-failure codes
if ret == -14 {
return Err(ChatChannelError::AuthenticationFailed(
"Session expired (ret=-14), please re-authenticate".into(),
));
}
// The iLink API may omit the `ret` field or return non-zero on the first // 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. // call. Always extract the cursor if present — it's needed for polling.
let initial_cursor = verify_result let initial_cursor = verify_result
@@ -404,6 +519,7 @@ impl ChatChannelBackend for WeixinBackend {
let client = self.client.clone(); let client = self.client.clone();
let bot_token = self.bot_token.clone(); let bot_token = self.bot_token.clone();
let base_url = self.base_url.clone(); let base_url = self.base_url.clone();
let wechat_uin = self.wechat_uin.clone();
let channel_id = self.channel_id; let channel_id = self.channel_id;
let status = self.status.clone(); let status = self.status.clone();
let reply_context = self.reply_context.clone(); let reply_context = self.reply_context.clone();
@@ -420,13 +536,13 @@ impl ChatChannelBackend for WeixinBackend {
let body = serde_json::json!({ let body = serde_json::json!({
"get_updates_buf": cursor, "get_updates_buf": cursor,
"base_info": { "channel_version": "1.0.2" } "base_info": { "channel_version": ILINK_CHANNEL_VERSION }
}); });
let result = tokio::select! { let result = tokio::select! {
r = client r = client
.post(format!("{base_url}/ilink/bot/getupdates")) .post(format!("{base_url}/ilink/bot/getupdates"))
.headers(WeixinBackend::build_headers(&bot_token)) .headers(WeixinBackend::build_headers(&bot_token, &wechat_uin))
.json(&body) .json(&body)
.send() => r, .send() => r,
_ = shutdown_rx.changed() => break, _ = shutdown_rx.changed() => break,
@@ -521,19 +637,21 @@ impl ChatChannelBackend for WeixinBackend {
.unwrap_or_default(); .unwrap_or_default();
// Store reply context for outbound messages // Store reply context for outbound messages
// Single lock scope to avoid TOCTOU
if !from_user_id.is_empty() && !context_token.is_empty() { if !from_user_id.is_empty() && !context_token.is_empty() {
let was_expired = reply_context let was_expired = {
.lock() let mut guard = reply_context.lock().await;
.await let was = guard
.as_ref() .as_ref()
.map(|c| c.expired) .map(|c| c.expired)
.unwrap_or(false); .unwrap_or(false);
*guard = Some(WeixinReplyContext {
*reply_context.lock().await = Some(WeixinReplyContext { to_user_id: from_user_id.to_string(),
to_user_id: from_user_id.to_string(), context_token: context_token.to_string(),
context_token: context_token.to_string(), expired: false,
expired: false, });
}); was
};
// Resend buffered messages with fresh context // Resend buffered messages with fresh context
if was_expired { if was_expired {
@@ -545,52 +663,29 @@ impl ChatChannelBackend for WeixinBackend {
buffered.len() buffered.len()
); );
for pending_text in &buffered { for pending_text in &buffered {
let cid = let ok = WeixinBackend::do_send(
format!("codeg-{}", uuid::Uuid::new_v4()); &client,
let send_body = serde_json::json!({ &base_url,
"msg": { &bot_token,
"from_user_id": "", &wechat_uin,
"to_user_id": from_user_id, from_user_id,
"client_id": cid, context_token,
"message_type": 2, pending_text,
"message_state": 2, &reply_context,
"context_token": context_token, &pending_messages,
"item_list": [{ )
"type": 1, .await;
"text_item": { "text": pending_text } if let Err(e) = ok {
}] eprintln!("[Weixin] resend error: {e}");
}, // Re-buffer remaining on hard error
"base_info": { "channel_version": "1.0.2" } let mut buf =
}); pending_messages.lock().await;
let send_ok = match client if buf.len() < MAX_PENDING_MESSAGES {
.post(format!( buf.push(pending_text.clone());
"{base_url}/ilink/bot/sendmessage"
))
.headers(WeixinBackend::build_headers(
&bot_token,
))
.json(&send_body)
.send()
.await
{
Ok(r) => {
let ok = r.status().is_success();
if !ok {
eprintln!("[Weixin] resend failed: HTTP {}", r.status());
}
ok
} }
Err(e) => {
eprintln!("[Weixin] resend error: {e}");
false
}
};
if !send_ok {
// Re-buffer remaining messages
pending_messages.lock().await.push(
pending_text.clone(),
);
} }
// If do_send returned Ok(false), it
// already re-buffered internally.
} }
} }
} }
@@ -662,14 +757,14 @@ impl ChatChannelBackend for WeixinBackend {
async fn test_connection(&self) -> Result<(), ChatChannelError> { async fn test_connection(&self) -> Result<(), ChatChannelError> {
let body = serde_json::json!({ let body = serde_json::json!({
"get_updates_buf": "", "get_updates_buf": "",
"base_info": { "channel_version": "1.0.2" } "base_info": { "channel_version": ILINK_CHANNEL_VERSION }
}); });
let url = format!("{}/ilink/bot/getupdates", self.base_url); let url = format!("{}/ilink/bot/getupdates", self.base_url);
let resp = self let resp = self
.client .client
.post(&url) .post(&url)
.headers(Self::build_headers(&self.bot_token)) .headers(Self::build_headers(&self.bot_token, &self.wechat_uin))
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -683,9 +778,7 @@ impl ChatChannelBackend for WeixinBackend {
eprintln!("[Weixin] test_connection: status={status_code}, body={resp_text}"); 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. let resp_json: serde_json::Value = serde_json::from_str(&resp_text).map_err(|e| {
// 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}")) ChatChannelError::ConnectionFailed(format!("Not valid JSON: {e}"))
})?; })?;
@@ -695,6 +788,15 @@ impl ChatChannelBackend for WeixinBackend {
))); )));
} }
// Check for known auth-failure codes
if let Some(ret) = resp_json.get("ret").and_then(|v| v.as_i64()) {
if ret == -14 {
return Err(ChatChannelError::AuthenticationFailed(
"Session expired (ret=-14)".into(),
));
}
}
Ok(()) Ok(())
} }
} }

View File

@@ -1,5 +1,5 @@
use crate::app_error::AppCommandError; use crate::app_error::AppCommandError;
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatus}; use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic};
use crate::chat_channel::manager::ChatChannelManager; use crate::chat_channel::manager::ChatChannelManager;
use crate::chat_channel::types::ChannelType; use crate::chat_channel::types::ChannelType;
use crate::db::service::{chat_channel_message_log_service, chat_channel_service}; use crate::db::service::{chat_channel_message_log_service, chat_channel_service};
@@ -351,7 +351,7 @@ pub async fn weixin_check_qrcode_core(
db: &AppDatabase, db: &AppDatabase,
channel_id: i32, channel_id: i32,
qrcode: &str, qrcode: &str,
) -> Result<WeixinQrcodeStatus, AppCommandError> { ) -> Result<WeixinQrcodeStatusPublic, AppCommandError> {
let result = crate::chat_channel::backends::weixin::weixin_check_qrcode(qrcode) let result = crate::chat_channel::backends::weixin::weixin_check_qrcode(qrcode)
.await .await
.map_err(AppCommandError::from)?; .map_err(AppCommandError::from)?;
@@ -386,7 +386,10 @@ pub async fn weixin_check_qrcode_core(
} }
} }
Ok(result) // Return only the status — never expose bot_token to the frontend
Ok(WeixinQrcodeStatusPublic {
status: result.status,
})
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -569,6 +572,6 @@ pub async fn weixin_check_qrcode(
db: tauri::State<'_, AppDatabase>, db: tauri::State<'_, AppDatabase>,
channel_id: i32, channel_id: i32,
qrcode: String, qrcode: String,
) -> Result<WeixinQrcodeStatus, AppCommandError> { ) -> Result<WeixinQrcodeStatusPublic, AppCommandError> {
weixin_check_qrcode_core(&db, channel_id, &qrcode).await weixin_check_qrcode_core(&db, channel_id, &qrcode).await
} }

View File

@@ -6,7 +6,7 @@ use serde::Deserialize;
use crate::app_error::AppCommandError; use crate::app_error::AppCommandError;
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::commands::chat_channel as cc_commands; use crate::commands::chat_channel as cc_commands;
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatus}; use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic};
use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo}; use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -268,7 +268,7 @@ pub struct WeixinCheckQrcodeParams {
pub async fn weixin_check_qrcode( pub async fn weixin_check_qrcode(
Extension(state): Extension<Arc<AppState>>, Extension(state): Extension<Arc<AppState>>,
Json(params): Json<WeixinCheckQrcodeParams>, Json(params): Json<WeixinCheckQrcodeParams>,
) -> Result<Json<WeixinQrcodeStatus>, AppCommandError> { ) -> Result<Json<WeixinQrcodeStatusPublic>, AppCommandError> {
let result = let result =
cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, &params.qrcode) cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, &params.qrcode)
.await?; .await?;

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { ExternalLink, Loader2, RefreshCw } from "lucide-react" import { AlertCircle, Loader2, RefreshCw } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -13,6 +13,11 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { weixinGetQrcode, weixinCheckQrcode } from "@/lib/api" import { weixinGetQrcode, weixinCheckQrcode } from "@/lib/api"
/** Client-side QR code expiry (5 minutes). */
const QR_EXPIRY_MS = 5 * 60 * 1000
/** Show a warning after this many consecutive polling failures. */
const POLL_ERROR_WARN_THRESHOLD = 3
interface WeixinQrcodeDialogProps { interface WeixinQrcodeDialogProps {
open: boolean open: boolean
channelId: number channelId: number
@@ -31,29 +36,40 @@ function WeixinQrcodeContent({
}) { }) {
const t = useTranslations("ChatChannelSettings") const t = useTranslations("ChatChannelSettings")
const [qrcodeImg, setQrcodeImg] = useState<string | null>(null) const [qrcodeImg, setQrcodeImg] = useState<string | null>(null)
const [qrcodeUrl, setQrcodeUrl] = useState<string | null>(null)
const [imgFailed, setImgFailed] = useState(false)
const [qrcodeId, setQrcodeId] = useState<string | null>(null) const [qrcodeId, setQrcodeId] = useState<string | null>(null)
const [status, setStatus] = useState<"loading" | "waiting" | "expired">( const [status, setStatus] = useState<"loading" | "waiting" | "expired">(
"loading" "loading"
) )
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [pollErrors, setPollErrors] = useState(0)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null) const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const expiryRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Stabilise callbacks via ref so the polling effect doesn't re-trigger
const onAuthSuccessRef = useRef(onAuthSuccess)
const onCloseRef = useRef(onClose)
useEffect(() => {
onAuthSuccessRef.current = onAuthSuccess
onCloseRef.current = onClose
})
const stopPolling = useCallback(() => { const stopPolling = useCallback(() => {
if (pollingRef.current) { if (pollingRef.current) {
clearInterval(pollingRef.current) clearInterval(pollingRef.current)
pollingRef.current = null pollingRef.current = null
} }
if (expiryRef.current) {
clearTimeout(expiryRef.current)
expiryRef.current = null
}
}, []) }, [])
const fetchQrcode = useCallback(async () => { const fetchQrcode = useCallback(async () => {
setStatus("loading") setStatus("loading")
setError(null) setError(null)
setQrcodeImg(null) setQrcodeImg(null)
setQrcodeUrl(null)
setImgFailed(false)
setQrcodeId(null) setQrcodeId(null)
setPollErrors(0)
stopPolling() stopPolling()
try { try {
@@ -62,15 +78,9 @@ function WeixinQrcodeContent({
if (result.qrcode_img_content) { if (result.qrcode_img_content) {
const raw = 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:") const imgSrc = raw.startsWith("data:")
? raw ? raw
: raw.startsWith("http") : `data:image/png;base64,${raw}`
? raw
: `data:image/png;base64,${raw}`
setQrcodeImg(imgSrc) setQrcodeImg(imgSrc)
} }
@@ -82,35 +92,42 @@ function WeixinQrcodeContent({
} }
}, [stopPolling]) }, [stopPolling])
// Fetch QR code on mount + cleanup polling on unmount // Fetch QR code on mount + cleanup on unmount
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount // eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount
fetchQrcode() fetchQrcode()
return () => stopPolling() return () => stopPolling()
}, [fetchQrcode, stopPolling]) }, [fetchQrcode, stopPolling])
// Start polling when we have a qrcodeId // Start polling + expiry timer when we have a qrcodeId
useEffect(() => { useEffect(() => {
if (!qrcodeId || status !== "waiting") return if (!qrcodeId || status !== "waiting") return
// Client-side expiry guard
expiryRef.current = setTimeout(() => {
stopPolling()
setStatus("expired")
}, QR_EXPIRY_MS)
pollingRef.current = setInterval(async () => { pollingRef.current = setInterval(async () => {
try { try {
const result = await weixinCheckQrcode(channelId, qrcodeId) const result = await weixinCheckQrcode(channelId, qrcodeId)
setPollErrors(0)
if (result.status === "confirmed") { if (result.status === "confirmed") {
stopPolling() stopPolling()
onAuthSuccess(channelId) onAuthSuccessRef.current(channelId)
onClose() onCloseRef.current()
} else if (result.status === "expired") { } else if (result.status === "expired") {
stopPolling() stopPolling()
setStatus("expired") setStatus("expired")
} }
} catch { } catch {
// Polling error — keep trying setPollErrors((n) => n + 1)
} }
}, 2000) }, 2000)
return () => stopPolling() return () => stopPolling()
}, [qrcodeId, status, channelId, stopPolling, onAuthSuccess, onClose]) }, [qrcodeId, status, channelId, stopPolling])
return ( return (
<div className="flex flex-col items-center gap-4 py-4"> <div className="flex flex-col items-center gap-4 py-4">
@@ -126,32 +143,23 @@ function WeixinQrcodeContent({
{status === "waiting" && qrcodeImg && ( {status === "waiting" && qrcodeImg && (
<> <>
{!imgFailed ? ( {/* eslint-disable-next-line @next/next/no-img-element */}
<> <img
{/* eslint-disable-next-line @next/next/no-img-element */} src={qrcodeImg}
<img alt="WeChat QR Code"
src={qrcodeImg} className="h-48 w-48 rounded-md"
alt="WeChat QR Code" referrerPolicy="no-referrer"
className="h-48 w-48 rounded-md" />
referrerPolicy="no-referrer"
onError={() => setImgFailed(true)}
/>
</>
) : qrcodeUrl ? (
<a
href={qrcodeUrl}
target="_blank"
rel="noopener noreferrer"
className="flex h-48 w-48 flex-col items-center justify-center gap-2 rounded-md border border-dashed text-sm text-muted-foreground hover:bg-muted"
>
<ExternalLink className="h-6 w-6" />
{t("weixinOpenQrcode")}
</a>
) : null}
<p className="flex items-center gap-2 text-xs text-muted-foreground"> <p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
{t("weixinWaitingScan")} {t("weixinWaitingScan")}
</p> </p>
{pollErrors >= POLL_ERROR_WARN_THRESHOLD && (
<div className="flex items-center gap-1.5 rounded-md border border-yellow-500/30 bg-yellow-500/5 px-3 py-1.5 text-xs text-yellow-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{t("weixinPollError")}
</div>
)}
</> </>
)} )}

View File

@@ -1719,7 +1719,7 @@
"weixinQrcodeExpired": "انتهت صلاحية رمز QR.", "weixinQrcodeExpired": "انتهت صلاحية رمز QR.",
"weixinRefreshQrcode": "تحديث", "weixinRefreshQrcode": "تحديث",
"weixinWaitingScan": "في انتظار المسح...", "weixinWaitingScan": "في انتظار المسح...",
"weixinOpenQrcode": "فتح رمز QR في المتصفح", "weixinPollError": "الاتصال غير مستقر، جاري إعادة المحاولة...",
"connect": "اتصال", "connect": "اتصال",
"disconnect": "قطع الاتصال", "disconnect": "قطع الاتصال",
"test": "اختبار الاتصال", "test": "اختبار الاتصال",

View File

@@ -1719,7 +1719,7 @@
"weixinQrcodeExpired": "QR-Code abgelaufen.", "weixinQrcodeExpired": "QR-Code abgelaufen.",
"weixinRefreshQrcode": "Aktualisieren", "weixinRefreshQrcode": "Aktualisieren",
"weixinWaitingScan": "Warten auf Scan...", "weixinWaitingScan": "Warten auf Scan...",
"weixinOpenQrcode": "QR-Code im Browser öffnen", "weixinPollError": "Verbindung instabil, erneuter Versuch...",
"connect": "Verbinden", "connect": "Verbinden",
"disconnect": "Trennen", "disconnect": "Trennen",
"test": "Verbindung testen", "test": "Verbindung testen",

View File

@@ -1719,7 +1719,7 @@
"weixinQrcodeExpired": "QR code expired.", "weixinQrcodeExpired": "QR code expired.",
"weixinRefreshQrcode": "Refresh", "weixinRefreshQrcode": "Refresh",
"weixinWaitingScan": "Waiting for scan...", "weixinWaitingScan": "Waiting for scan...",
"weixinOpenQrcode": "Open QR code in browser", "weixinPollError": "Connection unstable, retrying...",
"connect": "Connect", "connect": "Connect",
"disconnect": "Disconnect", "disconnect": "Disconnect",
"test": "Test Connection", "test": "Test Connection",

View File

@@ -1719,7 +1719,7 @@
"weixinQrcodeExpired": "El código QR ha expirado.", "weixinQrcodeExpired": "El código QR ha expirado.",
"weixinRefreshQrcode": "Actualizar", "weixinRefreshQrcode": "Actualizar",
"weixinWaitingScan": "Esperando escaneo...", "weixinWaitingScan": "Esperando escaneo...",
"weixinOpenQrcode": "Abrir código QR en el navegador", "weixinPollError": "Conexión inestable, reintentando...",
"connect": "Conectar", "connect": "Conectar",
"disconnect": "Desconectar", "disconnect": "Desconectar",
"test": "Probar conexión", "test": "Probar conexión",

View File

@@ -1719,7 +1719,7 @@
"weixinQrcodeExpired": "QR code expiré.", "weixinQrcodeExpired": "QR code expiré.",
"weixinRefreshQrcode": "Actualiser", "weixinRefreshQrcode": "Actualiser",
"weixinWaitingScan": "En attente du scan...", "weixinWaitingScan": "En attente du scan...",
"weixinOpenQrcode": "Ouvrir le QR code dans le navigateur", "weixinPollError": "Connexion instable, nouvelle tentative...",
"connect": "Connecter", "connect": "Connecter",
"disconnect": "Déconnecter", "disconnect": "Déconnecter",
"test": "Tester la connexion", "test": "Tester la connexion",

View File

@@ -1719,7 +1719,7 @@
"weixinQrcodeExpired": "QRコードの有効期限が切れました。", "weixinQrcodeExpired": "QRコードの有効期限が切れました。",
"weixinRefreshQrcode": "更新", "weixinRefreshQrcode": "更新",
"weixinWaitingScan": "スキャン待ち...", "weixinWaitingScan": "スキャン待ち...",
"weixinOpenQrcode": "ブラウザでQRコードを開く", "weixinPollError": "接続が不安定です。再試行中...",
"connect": "接続", "connect": "接続",
"disconnect": "切断", "disconnect": "切断",
"test": "接続テスト", "test": "接続テスト",

View File

@@ -1719,7 +1719,7 @@
"weixinQrcodeExpired": "QR 코드가 만료되었습니다.", "weixinQrcodeExpired": "QR 코드가 만료되었습니다.",
"weixinRefreshQrcode": "새로고침", "weixinRefreshQrcode": "새로고침",
"weixinWaitingScan": "스캔 대기 중...", "weixinWaitingScan": "스캔 대기 중...",
"weixinOpenQrcode": "브라우저에서 QR 코드 열기", "weixinPollError": "연결이 불안정합니다. 재시도 중...",
"connect": "연결", "connect": "연결",
"disconnect": "연결 해제", "disconnect": "연결 해제",
"test": "연결 테스트", "test": "연결 테스트",

View File

@@ -1719,7 +1719,7 @@
"weixinQrcodeExpired": "Código QR expirado.", "weixinQrcodeExpired": "Código QR expirado.",
"weixinRefreshQrcode": "Atualizar", "weixinRefreshQrcode": "Atualizar",
"weixinWaitingScan": "Aguardando escaneamento...", "weixinWaitingScan": "Aguardando escaneamento...",
"weixinOpenQrcode": "Abrir código QR no navegador", "weixinPollError": "Conexão instável, tentando novamente...",
"connect": "Conectar", "connect": "Conectar",
"disconnect": "Desconectar", "disconnect": "Desconectar",
"test": "Testar conexão", "test": "Testar conexão",

View File

@@ -1719,7 +1719,7 @@
"weixinQrcodeExpired": "二维码已过期。", "weixinQrcodeExpired": "二维码已过期。",
"weixinRefreshQrcode": "刷新二维码", "weixinRefreshQrcode": "刷新二维码",
"weixinWaitingScan": "等待扫码...", "weixinWaitingScan": "等待扫码...",
"weixinOpenQrcode": "在浏览器中打开二维码", "weixinPollError": "连接不稳定,正在重试...",
"connect": "连接", "connect": "连接",
"disconnect": "断开", "disconnect": "断开",
"test": "测试连接", "test": "测试连接",

View File

@@ -1719,7 +1719,7 @@
"weixinQrcodeExpired": "二維碼已過期。", "weixinQrcodeExpired": "二維碼已過期。",
"weixinRefreshQrcode": "重新整理", "weixinRefreshQrcode": "重新整理",
"weixinWaitingScan": "等待掃碼...", "weixinWaitingScan": "等待掃碼...",
"weixinOpenQrcode": "在瀏覽器中打開二維碼", "weixinPollError": "連接不穩定,正在重試...",
"connect": "連線", "connect": "連線",
"disconnect": "斷開", "disconnect": "斷開",
"test": "測試連線", "test": "測試連線",

View File

@@ -1455,8 +1455,6 @@ export async function weixinCheckQrcode(
qrcode: string qrcode: string
): Promise<{ ): Promise<{
status: string status: string
bot_token?: string
base_url?: string
}> { }> {
return getTransport().call("weixin_check_qrcode", { channelId, qrcode }) return getTransport().call("weixin_check_qrcode", { channelId, qrcode })
} }