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:
67
src-tauri/Cargo.lock
generated
67
src-tauri/Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ¶ms.qrcode)
|
cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, ¶ms.qrcode)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1719,7 +1719,7 @@
|
|||||||
"weixinQrcodeExpired": "انتهت صلاحية رمز QR.",
|
"weixinQrcodeExpired": "انتهت صلاحية رمز QR.",
|
||||||
"weixinRefreshQrcode": "تحديث",
|
"weixinRefreshQrcode": "تحديث",
|
||||||
"weixinWaitingScan": "في انتظار المسح...",
|
"weixinWaitingScan": "في انتظار المسح...",
|
||||||
"weixinOpenQrcode": "فتح رمز QR في المتصفح",
|
"weixinPollError": "الاتصال غير مستقر، جاري إعادة المحاولة...",
|
||||||
"connect": "اتصال",
|
"connect": "اتصال",
|
||||||
"disconnect": "قطع الاتصال",
|
"disconnect": "قطع الاتصال",
|
||||||
"test": "اختبار الاتصال",
|
"test": "اختبار الاتصال",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1719,7 +1719,7 @@
|
|||||||
"weixinQrcodeExpired": "QRコードの有効期限が切れました。",
|
"weixinQrcodeExpired": "QRコードの有効期限が切れました。",
|
||||||
"weixinRefreshQrcode": "更新",
|
"weixinRefreshQrcode": "更新",
|
||||||
"weixinWaitingScan": "スキャン待ち...",
|
"weixinWaitingScan": "スキャン待ち...",
|
||||||
"weixinOpenQrcode": "ブラウザでQRコードを開く",
|
"weixinPollError": "接続が不安定です。再試行中...",
|
||||||
"connect": "接続",
|
"connect": "接続",
|
||||||
"disconnect": "切断",
|
"disconnect": "切断",
|
||||||
"test": "接続テスト",
|
"test": "接続テスト",
|
||||||
|
|||||||
@@ -1719,7 +1719,7 @@
|
|||||||
"weixinQrcodeExpired": "QR 코드가 만료되었습니다.",
|
"weixinQrcodeExpired": "QR 코드가 만료되었습니다.",
|
||||||
"weixinRefreshQrcode": "새로고침",
|
"weixinRefreshQrcode": "새로고침",
|
||||||
"weixinWaitingScan": "스캔 대기 중...",
|
"weixinWaitingScan": "스캔 대기 중...",
|
||||||
"weixinOpenQrcode": "브라우저에서 QR 코드 열기",
|
"weixinPollError": "연결이 불안정합니다. 재시도 중...",
|
||||||
"connect": "연결",
|
"connect": "연결",
|
||||||
"disconnect": "연결 해제",
|
"disconnect": "연결 해제",
|
||||||
"test": "연결 테스트",
|
"test": "연결 테스트",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1719,7 +1719,7 @@
|
|||||||
"weixinQrcodeExpired": "二维码已过期。",
|
"weixinQrcodeExpired": "二维码已过期。",
|
||||||
"weixinRefreshQrcode": "刷新二维码",
|
"weixinRefreshQrcode": "刷新二维码",
|
||||||
"weixinWaitingScan": "等待扫码...",
|
"weixinWaitingScan": "等待扫码...",
|
||||||
"weixinOpenQrcode": "在浏览器中打开二维码",
|
"weixinPollError": "连接不稳定,正在重试...",
|
||||||
"connect": "连接",
|
"connect": "连接",
|
||||||
"disconnect": "断开",
|
"disconnect": "断开",
|
||||||
"test": "测试连接",
|
"test": "测试连接",
|
||||||
|
|||||||
@@ -1719,7 +1719,7 @@
|
|||||||
"weixinQrcodeExpired": "二維碼已過期。",
|
"weixinQrcodeExpired": "二維碼已過期。",
|
||||||
"weixinRefreshQrcode": "重新整理",
|
"weixinRefreshQrcode": "重新整理",
|
||||||
"weixinWaitingScan": "等待掃碼...",
|
"weixinWaitingScan": "等待掃碼...",
|
||||||
"weixinOpenQrcode": "在瀏覽器中打開二維碼",
|
"weixinPollError": "連接不穩定,正在重試...",
|
||||||
"connect": "連線",
|
"connect": "連線",
|
||||||
"disconnect": "斷開",
|
"disconnect": "斷開",
|
||||||
"test": "測試連線",
|
"test": "測試連線",
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user