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"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -860,12 +866,14 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"image",
|
||||
"keyring",
|
||||
"kill_tree",
|
||||
"mac-notification-sys",
|
||||
"notify",
|
||||
"portable-pty",
|
||||
"prost",
|
||||
"qrcode",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
@@ -2465,7 +2473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2576,6 +2584,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -3196,6 +3217,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
@@ -3211,7 +3242,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -4119,6 +4150,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -4340,6 +4384,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
@@ -6263,7 +6322,7 @@ dependencies = [
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
@@ -7014,7 +7073,7 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
|
||||
@@ -82,6 +82,8 @@ tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
prost = "0.13"
|
||||
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]
|
||||
tauri-plugin-window-state = { version = "2", optional = true }
|
||||
|
||||
@@ -13,6 +13,24 @@ use crate::chat_channel::traits::ChatChannelBackend;
|
||||
use crate::chat_channel::types::*;
|
||||
|
||||
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) ──
|
||||
|
||||
@@ -25,16 +43,24 @@ pub struct WeixinQrcodeInfo {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WeixinQrcodeStatus {
|
||||
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")]
|
||||
pub bot_token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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) ──
|
||||
|
||||
pub async fn weixin_get_qrcode() -> Result<WeixinQrcodeInfo, ChatChannelError> {
|
||||
let client = reqwest::Client::new();
|
||||
let client = qr_client();
|
||||
let resp = client
|
||||
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_bot_qrcode"))
|
||||
.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
|
||||
// convert to a data-URI so the frontend can display it without CORS issues.
|
||||
// If the image content is a URL, try to fetch the actual image bytes.
|
||||
// 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://") {
|
||||
match fetch_image_as_data_uri(&client, &raw_img).await {
|
||||
Ok(data_uri) => data_uri,
|
||||
Err(e) => {
|
||||
eprintln!("[Weixin] failed to proxy QR image: {e}, falling back to URL");
|
||||
raw_img
|
||||
Err(_) => {
|
||||
eprintln!("[Weixin] URL is an SPA page, generating QR code from URL");
|
||||
generate_qrcode_data_uri(&raw_img)?
|
||||
}
|
||||
}
|
||||
} 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.
|
||||
///
|
||||
/// 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(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
@@ -107,11 +137,10 @@ async fn fetch_image_as_data_uri(
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
|
||||
// If the server returned HTML instead of an image, bail out
|
||||
if content_type.contains("text/html") || content_type.contains("text/plain") {
|
||||
return Err(ChatChannelError::ConnectionFailed(format!(
|
||||
"Expected image but got {content_type}"
|
||||
)));
|
||||
return Err(ChatChannelError::ConnectionFailed(
|
||||
"QR code URL is an SPA page".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = resp
|
||||
@@ -119,16 +148,48 @@ async fn fetch_image_as_data_uri(
|
||||
.await
|
||||
.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);
|
||||
// Normalize content-type: strip parameters like charset
|
||||
let mime = content_type.split(';').next().unwrap_or("image/png").trim();
|
||||
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(
|
||||
qrcode: &str,
|
||||
) -> Result<WeixinQrcodeStatus, ChatChannelError> {
|
||||
let client = reqwest::Client::new();
|
||||
let client = qr_client();
|
||||
let resp = client
|
||||
.get(format!("{ILINK_BASE_URL}/ilink/bot/get_qrcode_status"))
|
||||
.query(&[("qrcode", qrcode)])
|
||||
@@ -181,10 +242,15 @@ pub struct WeixinBackend {
|
||||
reply_context: Arc<Mutex<Option<WeixinReplyContext>>>,
|
||||
/// Messages that failed due to expired context_token, resend on next refresh.
|
||||
pending_messages: Arc<Mutex<Vec<String>>>,
|
||||
/// Stable X-WECHAT-UIN value for this backend instance.
|
||||
wechat_uin: String,
|
||||
}
|
||||
|
||||
impl WeixinBackend {
|
||||
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 {
|
||||
bot_token,
|
||||
base_url,
|
||||
@@ -198,10 +264,11 @@ impl WeixinBackend {
|
||||
shutdown_tx: Arc::new(Mutex::new(None)),
|
||||
reply_context: Arc::new(Mutex::new(None)),
|
||||
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();
|
||||
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
|
||||
headers.insert(
|
||||
@@ -209,9 +276,7 @@ impl WeixinBackend {
|
||||
HeaderValue::from_static("ilink_bot_token"),
|
||||
);
|
||||
|
||||
let uin_raw = rand::thread_rng().gen::<u32>().to_string();
|
||||
let uin_b64 = B64.encode(uin_raw.as_bytes());
|
||||
if let Ok(val) = HeaderValue::from_str(&uin_b64) {
|
||||
if let Ok(val) = HeaderValue::from_str(wechat_uin) {
|
||||
headers.insert("X-WECHAT-UIN", val);
|
||||
}
|
||||
|
||||
@@ -223,6 +288,95 @@ impl WeixinBackend {
|
||||
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(
|
||||
&self,
|
||||
text: &str,
|
||||
@@ -249,79 +403,33 @@ impl WeixinBackend {
|
||||
"[Weixin] context expired, buffering message (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()));
|
||||
}
|
||||
|
||||
let client_id = format!("codeg-{}", uuid::Uuid::new_v4());
|
||||
let body = serde_json::json!({
|
||||
"msg": {
|
||||
"from_user_id": "",
|
||||
"to_user_id": to_user_id,
|
||||
"client_id": client_id,
|
||||
"message_type": 2,
|
||||
"message_state": 2,
|
||||
"context_token": context_token,
|
||||
"item_list": [{
|
||||
"type": 1,
|
||||
"text_item": { "text": text }
|
||||
}]
|
||||
},
|
||||
"base_info": { "channel_version": "1.0.2" }
|
||||
});
|
||||
|
||||
let url = format!("{}/ilink/bot/sendmessage", self.base_url);
|
||||
eprintln!(
|
||||
"[Weixin] sendmessage to={to_user_id}, context_token_len={}, text_len={}",
|
||||
context_token.len(),
|
||||
text.len()
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(Self::build_headers(&self.bot_token))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ChatChannelError::SendFailed(e.to_string()))?;
|
||||
|
||||
let status_code = resp.status();
|
||||
let resp_text = resp.text().await.unwrap_or_default();
|
||||
eprintln!("[Weixin] sendmessage response: status={status_code}, body={resp_text}");
|
||||
|
||||
if !status_code.is_success() {
|
||||
return Err(ChatChannelError::SendFailed(format!(
|
||||
"HTTP {status_code}: {resp_text}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Check for ret errors in response (e.g. -2 = context expired)
|
||||
if let Ok(resp_json) = serde_json::from_str::<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}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::do_send(
|
||||
&self.client,
|
||||
&self.base_url,
|
||||
&self.bot_token,
|
||||
&self.wechat_uin,
|
||||
&to_user_id,
|
||||
&context_token,
|
||||
text,
|
||||
&self.reply_context,
|
||||
&self.pending_messages,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(SentMessageId(String::new()))
|
||||
}
|
||||
@@ -348,7 +456,7 @@ impl ChatChannelBackend for WeixinBackend {
|
||||
// Verify auth by doing a quick getupdates with empty cursor
|
||||
let verify_body = serde_json::json!({
|
||||
"get_updates_buf": "",
|
||||
"base_info": { "channel_version": "1.0.2" }
|
||||
"base_info": { "channel_version": ILINK_CHANNEL_VERSION }
|
||||
});
|
||||
let url = format!("{}/ilink/bot/getupdates", self.base_url);
|
||||
eprintln!("[Weixin] verify POST {url}");
|
||||
@@ -356,7 +464,7 @@ impl ChatChannelBackend for WeixinBackend {
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(Self::build_headers(&self.bot_token))
|
||||
.headers(Self::build_headers(&self.bot_token, &self.wechat_uin))
|
||||
.json(&verify_body)
|
||||
.send()
|
||||
.await
|
||||
@@ -380,6 +488,13 @@ impl ChatChannelBackend for WeixinBackend {
|
||||
.and_then(|v| v.as_i64())
|
||||
.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
|
||||
// call. Always extract the cursor if present — it's needed for polling.
|
||||
let initial_cursor = verify_result
|
||||
@@ -404,6 +519,7 @@ impl ChatChannelBackend for WeixinBackend {
|
||||
let client = self.client.clone();
|
||||
let bot_token = self.bot_token.clone();
|
||||
let base_url = self.base_url.clone();
|
||||
let wechat_uin = self.wechat_uin.clone();
|
||||
let channel_id = self.channel_id;
|
||||
let status = self.status.clone();
|
||||
let reply_context = self.reply_context.clone();
|
||||
@@ -420,13 +536,13 @@ impl ChatChannelBackend for WeixinBackend {
|
||||
|
||||
let body = serde_json::json!({
|
||||
"get_updates_buf": cursor,
|
||||
"base_info": { "channel_version": "1.0.2" }
|
||||
"base_info": { "channel_version": ILINK_CHANNEL_VERSION }
|
||||
});
|
||||
|
||||
let result = tokio::select! {
|
||||
r = client
|
||||
.post(format!("{base_url}/ilink/bot/getupdates"))
|
||||
.headers(WeixinBackend::build_headers(&bot_token))
|
||||
.headers(WeixinBackend::build_headers(&bot_token, &wechat_uin))
|
||||
.json(&body)
|
||||
.send() => r,
|
||||
_ = shutdown_rx.changed() => break,
|
||||
@@ -521,19 +637,21 @@ impl ChatChannelBackend for WeixinBackend {
|
||||
.unwrap_or_default();
|
||||
|
||||
// Store reply context for outbound messages
|
||||
// Single lock scope to avoid TOCTOU
|
||||
if !from_user_id.is_empty() && !context_token.is_empty() {
|
||||
let was_expired = reply_context
|
||||
.lock()
|
||||
.await
|
||||
let was_expired = {
|
||||
let mut guard = reply_context.lock().await;
|
||||
let was = guard
|
||||
.as_ref()
|
||||
.map(|c| c.expired)
|
||||
.unwrap_or(false);
|
||||
|
||||
*reply_context.lock().await = Some(WeixinReplyContext {
|
||||
*guard = Some(WeixinReplyContext {
|
||||
to_user_id: from_user_id.to_string(),
|
||||
context_token: context_token.to_string(),
|
||||
expired: false,
|
||||
});
|
||||
was
|
||||
};
|
||||
|
||||
// Resend buffered messages with fresh context
|
||||
if was_expired {
|
||||
@@ -545,52 +663,29 @@ impl ChatChannelBackend for WeixinBackend {
|
||||
buffered.len()
|
||||
);
|
||||
for pending_text in &buffered {
|
||||
let cid =
|
||||
format!("codeg-{}", uuid::Uuid::new_v4());
|
||||
let send_body = serde_json::json!({
|
||||
"msg": {
|
||||
"from_user_id": "",
|
||||
"to_user_id": from_user_id,
|
||||
"client_id": cid,
|
||||
"message_type": 2,
|
||||
"message_state": 2,
|
||||
"context_token": context_token,
|
||||
"item_list": [{
|
||||
"type": 1,
|
||||
"text_item": { "text": pending_text }
|
||||
}]
|
||||
},
|
||||
"base_info": { "channel_version": "1.0.2" }
|
||||
});
|
||||
let send_ok = match client
|
||||
.post(format!(
|
||||
"{base_url}/ilink/bot/sendmessage"
|
||||
))
|
||||
.headers(WeixinBackend::build_headers(
|
||||
let ok = WeixinBackend::do_send(
|
||||
&client,
|
||||
&base_url,
|
||||
&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) => {
|
||||
&wechat_uin,
|
||||
from_user_id,
|
||||
context_token,
|
||||
pending_text,
|
||||
&reply_context,
|
||||
&pending_messages,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = ok {
|
||||
eprintln!("[Weixin] resend error: {e}");
|
||||
false
|
||||
// Re-buffer remaining on hard error
|
||||
let mut buf =
|
||||
pending_messages.lock().await;
|
||||
if buf.len() < MAX_PENDING_MESSAGES {
|
||||
buf.push(pending_text.clone());
|
||||
}
|
||||
};
|
||||
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> {
|
||||
let body = serde_json::json!({
|
||||
"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 resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.headers(Self::build_headers(&self.bot_token))
|
||||
.headers(Self::build_headers(&self.bot_token, &self.wechat_uin))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -683,9 +778,7 @@ impl ChatChannelBackend for WeixinBackend {
|
||||
|
||||
eprintln!("[Weixin] test_connection: status={status_code}, body={resp_text}");
|
||||
|
||||
// As long as we got a valid JSON response from the server, treat it as reachable.
|
||||
// The iLink API may return ret != 0 on first empty-cursor call.
|
||||
let _: serde_json::Value = serde_json::from_str(&resp_text).map_err(|e| {
|
||||
let resp_json: serde_json::Value = serde_json::from_str(&resp_text).map_err(|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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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::types::ChannelType;
|
||||
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,
|
||||
channel_id: i32,
|
||||
qrcode: &str,
|
||||
) -> Result<WeixinQrcodeStatus, AppCommandError> {
|
||||
) -> Result<WeixinQrcodeStatusPublic, AppCommandError> {
|
||||
let result = crate::chat_channel::backends::weixin::weixin_check_qrcode(qrcode)
|
||||
.await
|
||||
.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>,
|
||||
channel_id: i32,
|
||||
qrcode: String,
|
||||
) -> Result<WeixinQrcodeStatus, AppCommandError> {
|
||||
) -> Result<WeixinQrcodeStatusPublic, AppCommandError> {
|
||||
weixin_check_qrcode_core(&db, channel_id, &qrcode).await
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde::Deserialize;
|
||||
use crate::app_error::AppCommandError;
|
||||
use crate::app_state::AppState;
|
||||
use crate::commands::chat_channel as cc_commands;
|
||||
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatus};
|
||||
use crate::chat_channel::backends::weixin::{WeixinQrcodeInfo, WeixinQrcodeStatusPublic};
|
||||
use crate::models::chat_channel::{ChannelStatusInfo, ChatChannelInfo, ChatChannelMessageLogInfo};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -268,7 +268,7 @@ pub struct WeixinCheckQrcodeParams {
|
||||
pub async fn weixin_check_qrcode(
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
Json(params): Json<WeixinCheckQrcodeParams>,
|
||||
) -> Result<Json<WeixinQrcodeStatus>, AppCommandError> {
|
||||
) -> Result<Json<WeixinQrcodeStatusPublic>, AppCommandError> {
|
||||
let result =
|
||||
cc_commands::weixin_check_qrcode_core(&state.db, params.channel_id, ¶ms.qrcode)
|
||||
.await?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
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 { Button } from "@/components/ui/button"
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
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 {
|
||||
open: boolean
|
||||
channelId: number
|
||||
@@ -31,29 +36,40 @@ function WeixinQrcodeContent({
|
||||
}) {
|
||||
const t = useTranslations("ChatChannelSettings")
|
||||
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 [status, setStatus] = useState<"loading" | "waiting" | "expired">(
|
||||
"loading"
|
||||
)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pollErrors, setPollErrors] = useState(0)
|
||||
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(() => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current)
|
||||
pollingRef.current = null
|
||||
}
|
||||
if (expiryRef.current) {
|
||||
clearTimeout(expiryRef.current)
|
||||
expiryRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchQrcode = useCallback(async () => {
|
||||
setStatus("loading")
|
||||
setError(null)
|
||||
setQrcodeImg(null)
|
||||
setQrcodeUrl(null)
|
||||
setImgFailed(false)
|
||||
setQrcodeId(null)
|
||||
setPollErrors(0)
|
||||
stopPolling()
|
||||
|
||||
try {
|
||||
@@ -62,13 +78,7 @@ function WeixinQrcodeContent({
|
||||
|
||||
if (result.qrcode_img_content) {
|
||||
const raw = result.qrcode_img_content
|
||||
// Keep the original URL for fallback link
|
||||
if (raw.startsWith("http")) {
|
||||
setQrcodeUrl(raw)
|
||||
}
|
||||
const imgSrc = raw.startsWith("data:")
|
||||
? raw
|
||||
: raw.startsWith("http")
|
||||
? raw
|
||||
: `data:image/png;base64,${raw}`
|
||||
setQrcodeImg(imgSrc)
|
||||
@@ -82,35 +92,42 @@ function WeixinQrcodeContent({
|
||||
}
|
||||
}, [stopPolling])
|
||||
|
||||
// Fetch QR code on mount + cleanup polling on unmount
|
||||
// Fetch QR code on mount + cleanup on unmount
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch on mount
|
||||
fetchQrcode()
|
||||
return () => stopPolling()
|
||||
}, [fetchQrcode, stopPolling])
|
||||
|
||||
// Start polling when we have a qrcodeId
|
||||
// Start polling + expiry timer when we have a qrcodeId
|
||||
useEffect(() => {
|
||||
if (!qrcodeId || status !== "waiting") return
|
||||
|
||||
// Client-side expiry guard
|
||||
expiryRef.current = setTimeout(() => {
|
||||
stopPolling()
|
||||
setStatus("expired")
|
||||
}, QR_EXPIRY_MS)
|
||||
|
||||
pollingRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await weixinCheckQrcode(channelId, qrcodeId)
|
||||
setPollErrors(0)
|
||||
if (result.status === "confirmed") {
|
||||
stopPolling()
|
||||
onAuthSuccess(channelId)
|
||||
onClose()
|
||||
onAuthSuccessRef.current(channelId)
|
||||
onCloseRef.current()
|
||||
} else if (result.status === "expired") {
|
||||
stopPolling()
|
||||
setStatus("expired")
|
||||
}
|
||||
} catch {
|
||||
// Polling error — keep trying
|
||||
setPollErrors((n) => n + 1)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => stopPolling()
|
||||
}, [qrcodeId, status, channelId, stopPolling, onAuthSuccess, onClose])
|
||||
}, [qrcodeId, status, channelId, stopPolling])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
@@ -125,8 +142,6 @@ function WeixinQrcodeContent({
|
||||
)}
|
||||
|
||||
{status === "waiting" && qrcodeImg && (
|
||||
<>
|
||||
{!imgFailed ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -134,24 +149,17 @@ function WeixinQrcodeContent({
|
||||
alt="WeChat QR Code"
|
||||
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">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{t("weixinWaitingScan")}
|
||||
</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.",
|
||||
"weixinRefreshQrcode": "تحديث",
|
||||
"weixinWaitingScan": "في انتظار المسح...",
|
||||
"weixinOpenQrcode": "فتح رمز QR في المتصفح",
|
||||
"weixinPollError": "الاتصال غير مستقر، جاري إعادة المحاولة...",
|
||||
"connect": "اتصال",
|
||||
"disconnect": "قطع الاتصال",
|
||||
"test": "اختبار الاتصال",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "QR-Code abgelaufen.",
|
||||
"weixinRefreshQrcode": "Aktualisieren",
|
||||
"weixinWaitingScan": "Warten auf Scan...",
|
||||
"weixinOpenQrcode": "QR-Code im Browser öffnen",
|
||||
"weixinPollError": "Verbindung instabil, erneuter Versuch...",
|
||||
"connect": "Verbinden",
|
||||
"disconnect": "Trennen",
|
||||
"test": "Verbindung testen",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "QR code expired.",
|
||||
"weixinRefreshQrcode": "Refresh",
|
||||
"weixinWaitingScan": "Waiting for scan...",
|
||||
"weixinOpenQrcode": "Open QR code in browser",
|
||||
"weixinPollError": "Connection unstable, retrying...",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"test": "Test Connection",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "El código QR ha expirado.",
|
||||
"weixinRefreshQrcode": "Actualizar",
|
||||
"weixinWaitingScan": "Esperando escaneo...",
|
||||
"weixinOpenQrcode": "Abrir código QR en el navegador",
|
||||
"weixinPollError": "Conexión inestable, reintentando...",
|
||||
"connect": "Conectar",
|
||||
"disconnect": "Desconectar",
|
||||
"test": "Probar conexión",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "QR code expiré.",
|
||||
"weixinRefreshQrcode": "Actualiser",
|
||||
"weixinWaitingScan": "En attente du scan...",
|
||||
"weixinOpenQrcode": "Ouvrir le QR code dans le navigateur",
|
||||
"weixinPollError": "Connexion instable, nouvelle tentative...",
|
||||
"connect": "Connecter",
|
||||
"disconnect": "Déconnecter",
|
||||
"test": "Tester la connexion",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "QRコードの有効期限が切れました。",
|
||||
"weixinRefreshQrcode": "更新",
|
||||
"weixinWaitingScan": "スキャン待ち...",
|
||||
"weixinOpenQrcode": "ブラウザでQRコードを開く",
|
||||
"weixinPollError": "接続が不安定です。再試行中...",
|
||||
"connect": "接続",
|
||||
"disconnect": "切断",
|
||||
"test": "接続テスト",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "QR 코드가 만료되었습니다.",
|
||||
"weixinRefreshQrcode": "새로고침",
|
||||
"weixinWaitingScan": "스캔 대기 중...",
|
||||
"weixinOpenQrcode": "브라우저에서 QR 코드 열기",
|
||||
"weixinPollError": "연결이 불안정합니다. 재시도 중...",
|
||||
"connect": "연결",
|
||||
"disconnect": "연결 해제",
|
||||
"test": "연결 테스트",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "Código QR expirado.",
|
||||
"weixinRefreshQrcode": "Atualizar",
|
||||
"weixinWaitingScan": "Aguardando escaneamento...",
|
||||
"weixinOpenQrcode": "Abrir código QR no navegador",
|
||||
"weixinPollError": "Conexão instável, tentando novamente...",
|
||||
"connect": "Conectar",
|
||||
"disconnect": "Desconectar",
|
||||
"test": "Testar conexão",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "二维码已过期。",
|
||||
"weixinRefreshQrcode": "刷新二维码",
|
||||
"weixinWaitingScan": "等待扫码...",
|
||||
"weixinOpenQrcode": "在浏览器中打开二维码",
|
||||
"weixinPollError": "连接不稳定,正在重试...",
|
||||
"connect": "连接",
|
||||
"disconnect": "断开",
|
||||
"test": "测试连接",
|
||||
|
||||
@@ -1719,7 +1719,7 @@
|
||||
"weixinQrcodeExpired": "二維碼已過期。",
|
||||
"weixinRefreshQrcode": "重新整理",
|
||||
"weixinWaitingScan": "等待掃碼...",
|
||||
"weixinOpenQrcode": "在瀏覽器中打開二維碼",
|
||||
"weixinPollError": "連接不穩定,正在重試...",
|
||||
"connect": "連線",
|
||||
"disconnect": "斷開",
|
||||
"test": "測試連線",
|
||||
|
||||
@@ -1455,8 +1455,6 @@ export async function weixinCheckQrcode(
|
||||
qrcode: string
|
||||
): Promise<{
|
||||
status: string
|
||||
bot_token?: string
|
||||
base_url?: string
|
||||
}> {
|
||||
return getTransport().call("weixin_check_qrcode", { channelId, qrcode })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user