diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 099bf2ad5c..a72918c1ad 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -1924,12 +1924,15 @@ pub struct ConfigureBotRequest { #[derive(Debug, Deserialize)] pub struct WeixinQrStartRequest { pub base_url: Option, + pub existing_ilink_token: Option, + pub existing_bot_account_id: Option, } #[derive(Debug, Deserialize)] pub struct WeixinQrPollRequest { pub session_key: String, pub base_url: Option, + pub verify_code: Option, } #[tauri::command] @@ -1984,16 +1987,20 @@ pub async fn remote_connect_configure_bot(request: ConfigureBotRequest) -> Resul pub async fn remote_connect_weixin_qr_start( request: WeixinQrStartRequest, ) -> Result { - weixin::weixin_qr_start(request.base_url) - .await - .map_err(|e| format!("weixin qr start: {e}")) + weixin::weixin_qr_start_with_existing( + request.base_url, + request.existing_ilink_token, + request.existing_bot_account_id, + ) + .await + .map_err(|e| format!("weixin qr start: {e}")) } #[tauri::command] pub async fn remote_connect_weixin_qr_poll( request: WeixinQrPollRequest, ) -> Result { - weixin::weixin_qr_poll(&request.session_key, request.base_url) + weixin::weixin_qr_poll(&request.session_key, request.base_url, request.verify_code) .await .map_err(|e| format!("weixin qr poll: {e}")) } diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/weixin.rs b/src/crates/assembly/core/src/service/remote_connect/bot/weixin.rs index 10d1546fe0..db95baeaf0 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/weixin.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/weixin.rs @@ -45,14 +45,28 @@ pub struct WeixinBot { } pub async fn weixin_qr_start(base_url_override: Option) -> Result { - weixin_provider::weixin_qr_start(base_url_override).await + weixin_provider::weixin_qr_start(base_url_override, None, None).await +} + +pub async fn weixin_qr_start_with_existing( + base_url_override: Option, + existing_ilink_token: Option, + existing_bot_account_id: Option, +) -> Result { + weixin_provider::weixin_qr_start( + base_url_override, + existing_ilink_token, + existing_bot_account_id, + ) + .await } pub async fn weixin_qr_poll( session_key: &str, base_url_override: Option, + verify_code: Option, ) -> Result { - weixin_provider::weixin_qr_poll(session_key, base_url_override).await + weixin_provider::weixin_qr_poll(session_key, base_url_override, verify_code).await } impl WeixinBot { @@ -61,11 +75,12 @@ impl WeixinBot { } pub(crate) fn new_fenced(config: WeixinConfig, runtime_fence: BotRuntimeFence) -> Self { + let context_tokens = weixin_provider::load_context_tokens(&config.bot_account_id); Self { api: Arc::new(WeixinProviderClient::new(config)), pending_pairings: Arc::new(RwLock::new(HashMap::new())), chat_states: Arc::new(RwLock::new(HashMap::new())), - context_tokens: Arc::new(RwLock::new(HashMap::new())), + context_tokens: Arc::new(RwLock::new(context_tokens)), runtime_fence, } } @@ -137,6 +152,10 @@ impl WeixinBot { .unwrap_or(false) { tokens.remove(peer_id); + weixin_provider::save_context_tokens( + &self.api.config().bot_account_id, + &tokens, + ); warn!( "weixin: dropped stale context_token for peer {peer_id} after send error: {err}" ); @@ -147,6 +166,23 @@ impl WeixinBot { Ok(()) } + async fn remember_context_token(&self, peer_id: &str, token: String) { + if !self.runtime_fence.is_lifecycle_current() { + return; + } + let mut tokens = self.context_tokens.write().await; + tokens.insert(peer_id.to_string(), token); + weixin_provider::save_context_tokens(&self.api.config().bot_account_id, &tokens); + } + + pub async fn notify_start(&self) -> Result<()> { + self.api.notify_start().await + } + + pub async fn notify_stop(&self) -> Result<()> { + self.api.notify_stop().await + } + async fn context_token_for_peer(&self, peer_id: &str) -> Result { self.context_tokens .read() @@ -306,6 +342,7 @@ impl WeixinBot { ) -> Result { info!("Weixin bot waiting for pairing code (getupdates)..."); let mut buf = weixin_provider::load_sync_buf(&self.api.config().bot_account_id); + let mut long_poll_timeout = Duration::from_secs(LONG_POLL_TIMEOUT_SECS); loop { if *stop_rx.borrow() { @@ -318,7 +355,7 @@ impl WeixinBot { } result = self.api.get_updates_once( &buf, - Duration::from_secs(LONG_POLL_TIMEOUT_SECS), + long_poll_timeout, ) => result, }; @@ -330,6 +367,8 @@ impl WeixinBot { continue; } }; + long_poll_timeout = + weixin_provider::suggested_long_poll_timeout(&resp, long_poll_timeout); let ret = resp["ret"].as_i64().unwrap_or(0); let errcode = resp["errcode"].as_i64().unwrap_or(0); @@ -361,10 +400,7 @@ impl WeixinBot { continue; }; if let Some(token) = weixin_provider::context_token(msg) { - self.context_tokens - .write() - .await - .insert(peer.clone(), token); + self.remember_context_token(&peer, token).await; } let text = weixin_provider::body_from_message(msg).trim().to_string(); let language = current_bot_language().await; @@ -425,6 +461,7 @@ impl WeixinBot { info!("Weixin message loop started"); let mut stop = stop_rx; let mut buf = weixin_provider::load_sync_buf(&self.api.config().bot_account_id); + let mut long_poll_timeout = Duration::from_secs(LONG_POLL_TIMEOUT_SECS); loop { if *stop.borrow() { @@ -435,7 +472,7 @@ impl WeixinBot { _ = stop.changed() => break, result = self.api.get_updates_once( &buf, - Duration::from_secs(LONG_POLL_TIMEOUT_SECS), + long_poll_timeout, ) => result, }; @@ -447,6 +484,8 @@ impl WeixinBot { continue; } }; + long_poll_timeout = + weixin_provider::suggested_long_poll_timeout(&resp, long_poll_timeout); let ret = resp["ret"].as_i64().unwrap_or(0); let errcode = resp["errcode"].as_i64().unwrap_or(0); @@ -480,10 +519,7 @@ impl WeixinBot { continue; }; if let Some(token) = weixin_provider::context_token(msg) { - self.context_tokens - .write() - .await - .insert(peer.clone(), token); + self.remember_context_token(&peer, token).await; } let msg_value = msg.clone(); let bot = self.clone(); diff --git a/src/crates/assembly/core/src/service/remote_connect/mod.rs b/src/crates/assembly/core/src/service/remote_connect/mod.rs index 432441bd34..a6caa8d731 100644 --- a/src/crates/assembly/core/src/service/remote_connect/mod.rs +++ b/src/crates/assembly/core/src/service/remote_connect/mod.rs @@ -62,7 +62,7 @@ pub use remote_server::RemoteServer; use anyhow::Result; use bitfun_services_integrations::remote_connect::upload_mobile_web_to_relay; use embedded_relay_host::EmbeddedRelayHost; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -1330,6 +1330,11 @@ impl RemoteConnectService { if let Some(handle) = self.bot_weixin_handle.write().await.take() { handle.stop(); } + if let Some(previous_bot) = self.weixin_bot.write().await.take() { + if let Err(err) = previous_bot.notify_stop().await { + warn!("Weixin notify-stop failed during replacement: {err}"); + } + } let wx_cfg = bot::weixin::WeixinConfig { ilink_token: ilink_token.clone(), @@ -1363,6 +1368,9 @@ impl RemoteConnectService { *wx_bot_ref.write().await = Some(wx_bot.clone()); tokio::spawn(async move { + if let Err(err) = bot_for_pair.notify_start().await { + warn!("Weixin notify-start failed; continuing: {err}"); + } let mut stop_rx = stop_rx; match bot_for_pair.wait_for_pairing(&mut stop_rx).await { Ok(peer_id) => { @@ -1381,6 +1389,11 @@ impl RemoteConnectService { info!("Weixin pairing ended: {e}"); } } + if bot_slot.is_current(generation) { + if let Err(err) = bot_for_pair.notify_stop().await { + warn!("Weixin notify-stop failed: {err}"); + } + } }); *self.bot_weixin_handle.write().await = Some(BotHandle { stop_tx }); @@ -1503,6 +1516,11 @@ impl RemoteConnectService { if let Some(handle) = self.bot_weixin_handle.write().await.take() { handle.stop(); } + if let Some(previous_bot) = self.weixin_bot.write().await.take() { + if let Err(err) = previous_bot.notify_stop().await { + warn!("Weixin notify-stop failed during restore replacement: {err}"); + } + } let wx_cfg = bot::weixin::WeixinConfig { ilink_token: ilink_token.clone(), @@ -1533,9 +1551,19 @@ impl RemoteConnectService { *self.bot_connected_info.write().await = Some(format!("Weixin({cid})")); let bot_for_loop = wx_bot.clone(); + let bot_for_notify = wx_bot.clone(); + let bot_slot = self.bot_weixin_slot.clone(); tokio::spawn(async move { + if let Err(err) = bot_for_notify.notify_start().await { + warn!("Weixin notify-start failed during restore; continuing: {err}"); + } info!("Weixin bot restored from persistence, starting message loop"); bot_for_loop.run_message_loop(stop_rx).await; + if bot_slot.is_current(generation) { + if let Err(err) = bot_for_notify.notify_stop().await { + warn!("Weixin notify-stop failed after restored loop: {err}"); + } + } }); *self.bot_weixin_handle.write().await = Some(BotHandle { stop_tx }); @@ -1598,7 +1626,11 @@ impl RemoteConnectService { if let Some(handle) = self.bot_weixin_handle.write().await.take() { handle.stop(); } - *self.weixin_bot.write().await = None; + if let Some(weixin_bot) = self.weixin_bot.write().await.take() { + if let Err(err) = weixin_bot.notify_stop().await { + warn!("Weixin notify-stop failed during bot shutdown: {err}"); + } + } *self.bot_connected_info.write().await = None; info!("Bot connections stopped"); diff --git a/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs b/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs index 3bf6cefe4a..eae2d2f731 100644 --- a/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs +++ b/src/crates/services/services-integrations/src/remote_connect/bot/weixin.rs @@ -20,12 +20,16 @@ use tokio::sync::RwLock; const DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com"; const DEFAULT_ILINK_BOT_TYPE: &str = "3"; -const CHANNEL_VERSION: &str = "1.0.2"; +// Wire compatibility baseline audited against @tencent-weixin/openclaw-weixin 2.4.6. +const CHANNEL_VERSION: &str = "2.4.6"; +const ILINK_APP_ID: &str = "bot"; +const ILINK_APP_CLIENT_VERSION: &str = "132102"; // 0x00020406 +const BOT_AGENT: &str = concat!("BitFun/", env!("CARGO_PKG_VERSION")); const API_TIMEOUT_SECS: u64 = 20; const QR_POLL_TIMEOUT_SECS: u64 = 36; pub const WEIXIN_SESSION_EXPIRED_ERRCODE: i64 = -14; const SESSION_PAUSE_SECS: u64 = 3600; -const MAX_TEXT_CHUNK: usize = 3500; +const MAX_TEXT_CHUNK: usize = 4000; const MAX_QR_REFRESH: u32 = 3; const DEFAULT_CDN_BASE_URL: &str = "https://novac2c.cdn.weixin.qq.com/c2c"; pub const MAX_WEIXIN_FILE_BYTES: u64 = 30 * 1024 * 1024; @@ -52,6 +56,7 @@ pub struct WeixinQrStartResponse { pub enum WeixinQrPollStatus { Wait, Scanned, + NeedVerifyCode, Confirmed, Expired, Error, @@ -94,6 +99,11 @@ struct QrLoginSession { qrcode: String, started_at_ms: i64, refresh_count: u32, + current_base_url: String, + local_token_list: Vec, + existing_ilink_token: Option, + existing_bot_account_id: Option, + existing_base_url: Option, } enum QrSessionLookup { @@ -114,6 +124,7 @@ struct QrStatusApiResponse { bot_token: Option, ilink_bot_id: Option, baseurl: Option, + redirect_host: Option, } pub struct WeixinProviderClient { @@ -211,14 +222,14 @@ impl WeixinProviderClient { .await .insert(self.config.bot_account_id.clone(), until); warn!( - "weixin: session expired (err -14), pausing API for {}s", + "weixin: iLink token is stale (err -14), pausing API for {}s", SESSION_PAUSE_SECS ); } - fn build_auth_headers(&self, body: &str) -> reqwest::header::HeaderMap { - use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; - let mut headers = HeaderMap::new(); + fn build_auth_headers(&self) -> reqwest::header::HeaderMap { + use reqwest::header::{HeaderName, HeaderValue}; + let mut headers = build_common_headers(); headers.insert( HeaderName::from_static("content-type"), HeaderValue::from_static("application/json"), @@ -227,10 +238,6 @@ impl WeixinProviderClient { HeaderName::from_static("authorizationtype"), HeaderValue::from_static("ilink_bot_token"), ); - headers.insert( - HeaderName::from_static("content-length"), - HeaderValue::from_str(&body.len().to_string()).unwrap_or(HeaderValue::from_static("0")), - ); headers.insert( HeaderName::from_static("x-wechat-uin"), HeaderValue::from_str(&random_wechat_uin_header()) @@ -250,7 +257,7 @@ impl WeixinProviderClient { let client = reqwest::Client::builder().timeout(timeout).build()?; let resp = client .post(&url) - .headers(self.build_auth_headers(&body_str)) + .headers(self.build_auth_headers()) .body(body_str) .send() .await?; @@ -262,6 +269,8 @@ impl WeixinProviderClient { if endpoint.contains("sendmessage") || endpoint.contains("sendtyping") || endpoint.contains("getconfig") + || endpoint.contains("notifystart") + || endpoint.contains("notifystop") { if let Ok(value) = serde_json::from_str::(&text) { let ret = value["ret"].as_i64().unwrap_or(0); @@ -296,7 +305,7 @@ impl WeixinProviderClient { "ilink/bot/getupdates", json!({ "get_updates_buf": buf, - "base_info": { "channel_version": CHANNEL_VERSION } + "base_info": base_info() }), timeout, ) @@ -335,7 +344,7 @@ impl WeixinProviderClient { }); let body = json!({ "msg": msg, - "base_info": { "channel_version": CHANNEL_VERSION } + "base_info": base_info() }); self.post_ilink( "ilink/bot/sendmessage", @@ -360,10 +369,29 @@ impl WeixinProviderClient { } pub fn is_context_token_error(err: &anyhow::Error) -> bool { - let message = err.to_string(); - message.contains("application error") - || message.contains("context_token") - || message.contains("errcode=") + err.to_string() + .to_ascii_lowercase() + .contains("context_token") + } + + pub async fn notify_start(&self) -> Result<()> { + self.post_ilink( + "ilink/bot/msg/notifystart", + json!({ "base_info": base_info() }), + Duration::from_secs(API_TIMEOUT_SECS), + ) + .await?; + Ok(()) + } + + pub async fn notify_stop(&self) -> Result<()> { + self.post_ilink( + "ilink/bot/msg/notifystop", + json!({ "base_info": base_info() }), + Duration::from_secs(API_TIMEOUT_SECS), + ) + .await?; + Ok(()) } pub fn start_typing( @@ -421,7 +449,7 @@ impl WeixinProviderClient { ) -> Result { let mut body = json!({ "ilink_user_id": peer_id, - "base_info": { "channel_version": CHANNEL_VERSION } + "base_info": base_info() }); if let Some(token) = context_token { body["context_token"] = json!(token); @@ -468,7 +496,7 @@ impl WeixinProviderClient { "ilink_user_id": peer_id, "typing_ticket": ticket, "status": status, - "base_info": { "channel_version": CHANNEL_VERSION } + "base_info": base_info() }), Duration::from_secs(API_TIMEOUT_SECS), ) @@ -612,7 +640,7 @@ impl WeixinProviderClient { }); let body = json!({ "msg": msg, - "base_info": { "channel_version": CHANNEL_VERSION } + "base_info": base_info() }); self.post_ilink( "ilink/bot/sendmessage", @@ -750,7 +778,7 @@ impl WeixinProviderClient { "filesize": filesize, "no_need_thumb": true, "aeskey": aeskey_hex, - "base_info": { "channel_version": CHANNEL_VERSION } + "base_info": base_info() }), Duration::from_secs(API_TIMEOUT_SECS), ) @@ -823,30 +851,19 @@ impl WeixinProviderClient { } } -pub async fn weixin_qr_start(base_url_override: Option) -> Result { - let base = ensure_trailing_slash( - base_url_override - .as_deref() - .filter(|s| !s.is_empty()) - .unwrap_or(DEFAULT_BASE_URL), - ); - let url = format!( - "{}ilink/bot/get_bot_qrcode?bot_type={}", - base, - urlencoding::encode(DEFAULT_ILINK_BOT_TYPE) - ); - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(API_TIMEOUT_SECS)) - .build()?; - - let resp = client.get(&url).send().await?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!("get_bot_qrcode HTTP {status}: {body}")); - } - let parsed: QrCodeApiResponse = resp.json().await?; +pub async fn weixin_qr_start( + base_url_override: Option, + existing_ilink_token: Option, + existing_bot_account_id: Option, +) -> Result { + let existing_base_url = base_url_override.filter(|value| !value.trim().is_empty()); + // The reference client always obtains and refreshes login QR codes from the + // fixed iLink entrypoint. The account-specific base URL only applies after login. + let base = ensure_trailing_slash(DEFAULT_BASE_URL); + let existing_ilink_token = existing_ilink_token.filter(|value| !value.trim().is_empty()); + let existing_bot_account_id = existing_bot_account_id.filter(|value| !value.trim().is_empty()); + let local_token_list = existing_ilink_token.clone().into_iter().collect::>(); + let parsed = fetch_qr_code(&base, &local_token_list).await?; let qrcode = parsed .qrcode .filter(|s| !s.is_empty()) @@ -866,6 +883,11 @@ pub async fn weixin_qr_start(base_url_override: Option) -> Result) -> Result, + _base_url_override: Option, + verify_code: Option, ) -> Result { - let base = ensure_trailing_slash( - base_url_override - .as_deref() - .filter(|s| !s.is_empty()) - .unwrap_or(DEFAULT_BASE_URL), - ); + let base = ensure_trailing_slash(DEFAULT_BASE_URL); let lookup = { let mut sessions = qr_sessions() @@ -907,17 +925,25 @@ pub async fn weixin_qr_poll( match lookup { QrSessionLookup::Missing => Ok(qr_error("No active QR session. Start login again.")), QrSessionLookup::TimedOut => Ok(qr_error("QR session expired. Start again.")), - QrSessionLookup::Found(session) => poll_found_qr_session(session_key, session, &base).await, + QrSessionLookup::Found(session) => { + poll_found_qr_session(session_key, session, &base, verify_code.as_deref()).await + } } } async fn poll_found_qr_session( session_key: &str, session: QrLoginSession, - base: &str, + original_base: &str, + verify_code: Option<&str>, ) -> Result { + let base = ensure_trailing_slash(&session.current_base_url); let qrcode_enc = urlencoding::encode(&session.qrcode); - let url = format!("{}ilink/bot/get_qrcode_status?qrcode={}", base, qrcode_enc); + let mut url = format!("{}ilink/bot/get_qrcode_status?qrcode={}", base, qrcode_enc); + if let Some(code) = verify_code.map(str::trim).filter(|code| !code.is_empty()) { + url.push_str("&verify_code="); + url.push_str(&urlencoding::encode(code)); + } let client = reqwest::Client::builder() .timeout(Duration::from_secs(QR_POLL_TIMEOUT_SECS)) @@ -925,7 +951,7 @@ async fn poll_found_qr_session( let resp = client .get(&url) - .header("iLink-App-ClientVersion", "1") + .headers(build_common_headers()) .send() .await; @@ -935,29 +961,16 @@ async fn poll_found_qr_session( if err.is_timeout() { return Ok(qr_wait("waiting")); } - qr_sessions() - .lock() - .map_err(|lock_err| anyhow!("qr session lock: {lock_err}"))? - .remove(session_key); - return Err(anyhow!("get_qrcode_status: {err}")); + warn!("weixin: transient QR status request failed; retrying: {err}"); + return Ok(qr_wait("waiting")); } }; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - qr_sessions() - .lock() - .map_err(|err| anyhow!("qr session lock: {err}"))? - .remove(session_key); - return Ok(WeixinQrPollResponse { - status: WeixinQrPollStatus::Error, - message: format!("HTTP {status}: {body}"), - qr_image_url: None, - ilink_token: None, - bot_account_id: None, - base_url: None, - }); + warn!("weixin: transient QR status HTTP {status}; retrying: {body}"); + return Ok(qr_wait("waiting")); } let status_json: QrStatusApiResponse = resp.json().await?; @@ -971,12 +984,79 @@ async fn poll_found_qr_session( bot_account_id: None, base_url: None, }), - "confirmed" => confirm_qr_session(session_key, status_json, base), - "expired" => refresh_qr_session(session_key, base).await, + "need_verifycode" => Ok(qr_need_verify_code()), + "verify_code_blocked" => refresh_qr_session(session_key, original_base).await, + "scaned_but_redirect" => redirect_qr_session(session_key, status_json), + "binded_redirect" => confirm_existing_qr_session(session_key, session), + "confirmed" => confirm_qr_session(session_key, status_json, &base), + "expired" => refresh_qr_session(session_key, original_base).await, other => Ok(qr_wait(other)), } } +fn redirect_qr_session( + session_key: &str, + status_json: QrStatusApiResponse, +) -> Result { + let redirect_host = status_json + .redirect_host + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("scaned_but_redirect missing redirect_host"))?; + let redirect_base = if redirect_host.starts_with("https://") { + ensure_trailing_slash(&redirect_host) + } else { + ensure_trailing_slash(&format!("https://{redirect_host}")) + }; + let mut sessions = qr_sessions() + .lock() + .map_err(|err| anyhow!("qr session lock: {err}"))?; + let session = sessions + .get_mut(session_key) + .ok_or_else(|| anyhow!("QR session lost during redirect"))?; + session.current_base_url = redirect_base; + Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Scanned, + message: "Scanned; continuing on the assigned iLink host.".to_string(), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + }) +} + +fn confirm_existing_qr_session( + session_key: &str, + session: QrLoginSession, +) -> Result { + let token = session + .existing_ilink_token + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("binded_redirect received without existing ilink token"))?; + let account_id = session + .existing_bot_account_id + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("binded_redirect received without existing bot account id"))?; + qr_sessions() + .lock() + .map_err(|err| anyhow!("qr session lock: {err}"))? + .remove(session_key); + Ok(WeixinQrPollResponse { + status: WeixinQrPollStatus::Confirmed, + message: "WeChat was already linked; reused the existing login.".to_string(), + qr_image_url: None, + ilink_token: Some(token), + bot_account_id: Some(account_id), + base_url: Some( + session + .existing_base_url + .unwrap_or(session.current_base_url) + .trim_end_matches('/') + .to_string(), + ), + }) +} + fn confirm_qr_session( session_key: &str, status_json: QrStatusApiResponse, @@ -1012,7 +1092,7 @@ fn confirm_qr_session( } async fn refresh_qr_session(session_key: &str, base: &str) -> Result { - let over_limit = { + let local_token_list = { let mut sessions = qr_sessions() .lock() .map_err(|err| anyhow!("qr session lock: {err}"))?; @@ -1022,33 +1102,26 @@ async fn refresh_qr_session(session_key: &str, base: &str) -> Result MAX_QR_REFRESH { sessions.remove(session_key); - true + None } else { - false + Some(session.local_token_list.clone()) } }; - if over_limit { + let Some(local_token_list) = local_token_list else { return Ok(qr_error("QR expired too many times; start again.")); - } + }; - let refresh_url = format!( - "{}ilink/bot/get_bot_qrcode?bot_type={}", - base, - urlencoding::encode(DEFAULT_ILINK_BOT_TYPE) - ); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(API_TIMEOUT_SECS)) - .build()?; - let refresh = client.get(&refresh_url).send().await?; - if !refresh.status().is_success() { - qr_sessions() - .lock() - .map_err(|err| anyhow!("qr session lock: {err}"))? - .remove(session_key); - return Ok(qr_error("Failed to refresh QR.")); - } - let parsed: QrCodeApiResponse = refresh.json().await?; + let parsed = match fetch_qr_code(base, &local_token_list).await { + Ok(parsed) => parsed, + Err(err) => { + qr_sessions() + .lock() + .map_err(|lock_err| anyhow!("qr session lock: {lock_err}"))? + .remove(session_key); + return Ok(qr_error(&format!("Failed to refresh QR: {err}"))); + } + }; let qrcode = parsed .qrcode .filter(|s| !s.is_empty()) @@ -1065,6 +1138,7 @@ async fn refresh_qr_session(session_key: &str, base: &str) -> Result HashMap { + let path = context_tokens_path(bot_account_id); + let Ok(raw) = std::fs::read_to_string(path) else { + return HashMap::new(); + }; + serde_json::from_str::>(&raw) + .unwrap_or_default() + .into_iter() + .filter(|(peer, token)| !peer.trim().is_empty() && !token.trim().is_empty()) + .collect() +} + +pub fn save_context_tokens(bot_account_id: &str, tokens: &HashMap) { + use std::io::Write; + + let path = context_tokens_path(bot_account_id); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let Ok(serialized) = serde_json::to_vec(tokens) else { + warn!("weixin: failed to serialize context token cache"); + return; + }; + let write_result = (|| -> std::io::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&path)?; + file.write_all(&serialized)?; + file.sync_all() + })(); + if let Err(err) = write_result { + warn!( + "weixin: failed to save context token cache {}: {err}", + path.display() + ); + } +} + pub fn is_user_message(msg: &Value) -> bool { msg["message_type"].as_i64() == Some(1) } @@ -1114,6 +1231,14 @@ pub fn context_token(msg: &Value) -> Option { .filter(|s| !s.is_empty()) } +pub fn suggested_long_poll_timeout(response: &Value, current: Duration) -> Duration { + response["longpolling_timeout_ms"] + .as_u64() + .filter(|millis| (1_000..=120_000).contains(millis)) + .map(Duration::from_millis) + .unwrap_or(current) +} + pub fn has_inbound_image_items(msg: &Value) -> bool { let Some(items) = msg["item_list"].as_array() else { return false; @@ -1333,6 +1458,27 @@ fn random_wechat_uin_header() -> String { B64.encode(n.to_string().as_bytes()) } +fn build_common_headers() -> reqwest::header::HeaderMap { + use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("ilink-app-id"), + HeaderValue::from_static(ILINK_APP_ID), + ); + headers.insert( + HeaderName::from_static("ilink-app-clientversion"), + HeaderValue::from_static(ILINK_APP_CLIENT_VERSION), + ); + headers +} + +fn base_info() -> Value { + json!({ + "channel_version": CHANNEL_VERSION, + "bot_agent": BOT_AGENT, + }) +} + fn ensure_trailing_slash(url: &str) -> String { if url.ends_with('/') { url.to_string() @@ -1341,6 +1487,44 @@ fn ensure_trailing_slash(url: &str) -> String { } } +async fn fetch_qr_code(base: &str, local_token_list: &[String]) -> Result { + use reqwest::header::{HeaderName, HeaderValue}; + let url = format!( + "{}ilink/bot/get_bot_qrcode?bot_type={}", + ensure_trailing_slash(base), + urlencoding::encode(DEFAULT_ILINK_BOT_TYPE) + ); + let mut headers = build_common_headers(); + headers.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static("application/json"), + ); + headers.insert( + HeaderName::from_static("authorizationtype"), + HeaderValue::from_static("ilink_bot_token"), + ); + headers.insert( + HeaderName::from_static("x-wechat-uin"), + HeaderValue::from_str(&random_wechat_uin_header()) + .unwrap_or(HeaderValue::from_static("MA==")), + ); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(API_TIMEOUT_SECS)) + .build()?; + let response = client + .post(&url) + .headers(headers) + .json(&json!({ "local_token_list": local_token_list })) + .send() + .await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!("get_bot_qrcode HTTP {status}: {body}")); + } + Ok(response.json().await?) +} + fn sync_buf_path(bot_account_id: &str) -> PathBuf { let base = super::super::bitfun_home_dir().unwrap_or_else(|| std::env::temp_dir().join(".bitfun")); @@ -1348,6 +1532,13 @@ fn sync_buf_path(bot_account_id: &str) -> PathBuf { .join(format!("{bot_account_id}_get_updates_buf.txt")) } +fn context_tokens_path(bot_account_id: &str) -> PathBuf { + let base = + super::super::bitfun_home_dir().unwrap_or_else(|| std::env::temp_dir().join(".bitfun")); + base.join("weixin") + .join(format!("{bot_account_id}_context_tokens.json")) +} + fn chunk_text_for_weixin(text: &str) -> Vec { if text.len() <= MAX_TEXT_CHUNK { return vec![text.to_string()]; @@ -1387,6 +1578,17 @@ fn qr_wait(message: &str) -> WeixinQrPollResponse { } } +fn qr_need_verify_code() -> WeixinQrPollResponse { + WeixinQrPollResponse { + status: WeixinQrPollStatus::NeedVerifyCode, + message: "Enter the number displayed in WeChat to continue.".to_string(), + qr_image_url: None, + ilink_token: None, + bot_account_id: None, + base_url: None, + } +} + fn qr_error(message: &str) -> WeixinQrPollResponse { WeixinQrPollResponse { status: WeixinQrPollStatus::Error, @@ -1410,8 +1612,10 @@ mod tests { ); assert!(WeixinProviderClient::is_context_token_error(&app_err)); - let app_err_short = anyhow!("upstream returned errcode=42 unauthorized"); - assert!(WeixinProviderClient::is_context_token_error(&app_err_short)); + let unrelated_app_err = anyhow!("upstream returned errcode=42 unauthorized"); + assert!(!WeixinProviderClient::is_context_token_error( + &unrelated_app_err + )); let net_err = anyhow!("error sending request: connection refused"); assert!(!WeixinProviderClient::is_context_token_error(&net_err)); @@ -1420,6 +1624,56 @@ mod tests { assert!(!WeixinProviderClient::is_context_token_error(&http_err)); } + #[test] + fn wire_metadata_matches_audited_reference_contract() { + let metadata = base_info(); + assert_eq!(metadata["channel_version"], CHANNEL_VERSION); + assert_eq!(metadata["bot_agent"], BOT_AGENT); + + let headers = build_common_headers(); + assert_eq!( + headers + .get("iLink-App-Id") + .and_then(|value| value.to_str().ok()), + Some(ILINK_APP_ID) + ); + assert_eq!( + headers + .get("iLink-App-ClientVersion") + .and_then(|value| value.to_str().ok()), + Some(ILINK_APP_CLIENT_VERSION) + ); + } + + #[test] + fn verify_code_status_uses_frontend_wire_name() { + assert_eq!( + serde_json::to_string(&WeixinQrPollStatus::NeedVerifyCode).unwrap(), + "\"need_verify_code\"" + ); + } + + #[test] + fn text_chunk_limit_matches_reference_channel() { + let chunks = chunk_text_for_weixin(&"a".repeat(MAX_TEXT_CHUNK + 1)); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), MAX_TEXT_CHUNK); + assert_eq!(chunks[1], "a"); + } + + #[test] + fn accepts_bounded_server_long_poll_timeout() { + let fallback = Duration::from_secs(36); + assert_eq!( + suggested_long_poll_timeout(&json!({ "longpolling_timeout_ms": 35_000 }), fallback), + Duration::from_secs(35) + ); + assert_eq!( + suggested_long_poll_timeout(&json!({ "longpolling_timeout_ms": 999_999 }), fallback), + fallback + ); + } + #[test] fn aes_ecb_roundtrip() { let key = [9u8; 16]; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts index 90b2c67bca..fcb3342fd8 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.contract.test.ts @@ -239,6 +239,18 @@ describe('Remote Connect safety contracts', () => { expect(rejectionCleanup).toContain('setWeixinAwaitingPhoneConfirm(false)'); }); + it('auto-starts Weixin after QR login without exposing a redundant Connect action', () => { + const botContent = dialogSource.slice( + dialogSource.indexOf('const renderBotContent'), + dialogSource.indexOf('// ── Layout'), + ); + + expect(botContent).toContain("botTab !== 'weixin'"); + expect(botContent).not.toContain('botWeixinLinked'); + expect(dialogSource).toContain("t('remoteConnect.botWeixinRestriction')"); + expect(dialogSource).toContain('prepareAndStartWeixinBotFromQr'); + }); + it('restores an existing relay pairing as cancellable in-progress UI', () => { const restoreFlow = dialogSource.slice( dialogSource.indexOf('// On dialog open: check if a connection'), diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index 16d6671a09..758d73b3a8 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -533,6 +533,16 @@ text-align: center; } +.bitfun-remote-connect__weixin-verify { + display: flex; + width: 100%; + max-width: 360px; + margin: 0 auto; + flex-direction: column; + align-items: center; + gap: 8px; +} + .bitfun-remote-connect__weixin-qr-img { display: block; max-width: 220px; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index 513f9e19e2..5322f337e2 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -184,6 +184,9 @@ export const RemoteConnectDialog: React.FC = ({ const [weixinQrSessionKey, setWeixinQrSessionKey] = useState(null); const [weixinQrImageUrl, setWeixinQrImageUrl] = useState(null); const [weixinAwaitingPhoneConfirm, setWeixinAwaitingPhoneConfirm] = useState(false); + const [weixinNeedsVerifyCode, setWeixinNeedsVerifyCode] = useState(false); + const [weixinVerifyCode, setWeixinVerifyCode] = useState(''); + const [weixinQrPollNonce, setWeixinQrPollNonce] = useState(0); const handleTabArrowKey = useCallback((event: React.KeyboardEvent) => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; @@ -222,6 +225,7 @@ export const RemoteConnectDialog: React.FC = ({ promise: Promise; } | null>(null); const cleanupPromiseRef = useRef>(Promise.resolve()); + const weixinVerifyCodeRef = useRef(null); const isOpenRef = useRef(isOpen); connectionOwnerRef.current = connectionOwner; isOpenRef.current = isOpen; @@ -248,6 +252,9 @@ export const RemoteConnectDialog: React.FC = ({ setWeixinQrSessionKey(null); setWeixinQrImageUrl(null); setWeixinAwaitingPhoneConfirm(false); + setWeixinNeedsVerifyCode(false); + setWeixinVerifyCode(''); + weixinVerifyCodeRef.current = null; setLoading(false); const previousCleanup = cleanupPromiseRef.current; @@ -530,21 +537,34 @@ export const RemoteConnectDialog: React.FC = ({ && pendingOwnerRef.current === 'bot' ); void (async () => { + let verifyCode = weixinVerifyCodeRef.current; + weixinVerifyCodeRef.current = null; while (isCurrent()) { try { - const p = await remoteConnectAPI.weixinQrPoll(key); + const p = await remoteConnectAPI.weixinQrPoll(key, null, verifyCode); + verifyCode = null; if (!isCurrent()) return; if (p.status === 'scanned') { setWeixinQrImageUrl(null); setWeixinAwaitingPhoneConfirm(true); + setWeixinNeedsVerifyCode(false); + setWeixinVerifyCode(''); await new Promise(resolve => setTimeout(resolve, 750)); continue; } + if (p.status === 'need_verify_code') { + setWeixinQrImageUrl(null); + setWeixinAwaitingPhoneConfirm(false); + setWeixinNeedsVerifyCode(true); + return; + } if (p.status === 'confirmed' && p.ilink_token && p.bot_account_id) { const token = p.ilink_token; const base = p.base_url ?? ''; const bid = p.bot_account_id; setWeixinAwaitingPhoneConfirm(false); + setWeixinNeedsVerifyCode(false); + setWeixinVerifyCode(''); setWeixinIlinkToken(token); setWeixinBaseUrl(base); setWeixinBotAccountId(bid); @@ -596,11 +616,15 @@ export const RemoteConnectDialog: React.FC = ({ setWeixinQrSessionKey(null); setWeixinQrImageUrl(null); setWeixinAwaitingPhoneConfirm(false); + setWeixinNeedsVerifyCode(false); + setWeixinVerifyCode(''); return; } if (p.status === 'expired' && p.qr_image_url) { setWeixinQrImageUrl(p.qr_image_url); setWeixinAwaitingPhoneConfirm(false); + setWeixinNeedsVerifyCode(false); + setWeixinVerifyCode(''); } await new Promise(resolve => setTimeout(resolve, 750)); } catch (e: unknown) { @@ -610,6 +634,8 @@ export const RemoteConnectDialog: React.FC = ({ setWeixinQrSessionKey(null); setWeixinQrImageUrl(null); setWeixinAwaitingPhoneConfirm(false); + setWeixinNeedsVerifyCode(false); + setWeixinVerifyCode(''); }); return; } @@ -618,7 +644,7 @@ export const RemoteConnectDialog: React.FC = ({ return () => { cancelled = true; }; - }, [weixinQrSessionKey, prepareAndStartWeixinBotFromQr, startPolling]); + }, [weixinQrSessionKey, weixinQrPollNonce, prepareAndStartWeixinBotFromQr, startPolling]); // ── Connection handlers ────────────────────────────────────────── @@ -742,11 +768,18 @@ export const RemoteConnectDialog: React.FC = ({ pendingOwnerRef.current = 'bot'; setError(null); setWeixinAwaitingPhoneConfirm(false); + setWeixinNeedsVerifyCode(false); + setWeixinVerifyCode(''); + weixinVerifyCodeRef.current = null; setLoading(true); try { await cleanupPromiseRef.current.catch(() => undefined); if (!isOpenRef.current || operationGenerationRef.current !== operationGeneration) return; - const r = await remoteConnectAPI.weixinQrStart(null); + const r = await remoteConnectAPI.weixinQrStart( + weixinBaseUrl || null, + weixinIlinkToken || null, + weixinBotAccountId || null, + ); if (!isOpenRef.current || operationGenerationRef.current !== operationGeneration) return; setWeixinQrSessionKey(r.session_key); setWeixinQrImageUrl(r.qr_image_url); @@ -760,7 +793,15 @@ export const RemoteConnectDialog: React.FC = ({ setLoading(false); } } - }, [hasAgreedDisclaimer]); + }, [hasAgreedDisclaimer, weixinBaseUrl, weixinBotAccountId, weixinIlinkToken]); + + const handleSubmitWeixinVerifyCode = useCallback(() => { + const code = weixinVerifyCode.trim(); + if (!code || !weixinQrSessionKey) return; + weixinVerifyCodeRef.current = code; + setWeixinNeedsVerifyCode(false); + setWeixinQrPollNonce(value => value + 1); + }, [weixinQrSessionKey, weixinVerifyCode]); const handleCancelWeixinQr = useCallback(() => { void cancelPendingWork(); @@ -1067,6 +1108,11 @@ export const RemoteConnectDialog: React.FC = ({ if (isBotConnected && connectedBotTab === botTab) { return (
+ {botTab === 'weixin' && renderInfoCard( +

+ {t('remoteConnect.botWeixinRestriction')} +

, + )}
{t('remoteConnect.stateConnected')}
@@ -1179,6 +1225,9 @@ export const RemoteConnectDialog: React.FC = ({

{t('remoteConnect.botWeixinIntro')}

1. {t('remoteConnect.botWeixinStep1')}

2. {t('remoteConnect.botWeixinStep2')}

+

+ {t('remoteConnect.botWeixinRestriction')} +

, )} {weixinQrImageUrl && ( @@ -1225,7 +1274,34 @@ export const RemoteConnectDialog: React.FC = ({ )} - {!weixinQrSessionKey && !weixinQrImageUrl && ( + {weixinQrSessionKey && !weixinQrImageUrl && weixinNeedsVerifyCode && ( +
+ setWeixinVerifyCode(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSubmitWeixinVerifyCode(); + }} + /> +

+ {t('remoteConnect.botWeixinVerifyCodeHint')} +

+ +
+ )} + {!weixinQrSessionKey && !weixinQrImageUrl && !weixinNeedsVerifyCode && ( )} - {weixinIlinkToken && weixinBotAccountId && !weixinQrSessionKey && ( -

{t('remoteConnect.botWeixinLinked')}

- )} )} {renderErrorBlock()} - + {botTab !== 'weixin' && ( + + )} ); }; diff --git a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts index 883bfb5ac4..b5334e2a08 100644 --- a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts @@ -71,6 +71,7 @@ export interface WeixinQrStartResponse { export type WeixinQrPollStatus = | 'wait' | 'scanned' + | 'need_verify_code' | 'confirmed' | 'expired' | 'error'; @@ -256,15 +257,31 @@ class RemoteConnectAPIService { } } - async weixinQrStart(baseUrl?: string | null): Promise { + async weixinQrStart( + baseUrl?: string | null, + existingIlinkToken?: string | null, + existingBotAccountId?: string | null, + ): Promise { return await this.adapter.request('remote_connect_weixin_qr_start', { - request: { base_url: baseUrl ?? null }, + request: { + base_url: baseUrl ?? null, + existing_ilink_token: existingIlinkToken ?? null, + existing_bot_account_id: existingBotAccountId ?? null, + }, }); } - async weixinQrPoll(sessionKey: string, baseUrl?: string | null): Promise { + async weixinQrPoll( + sessionKey: string, + baseUrl?: string | null, + verifyCode?: string | null, + ): Promise { return await this.adapter.request('remote_connect_weixin_qr_poll', { - request: { session_key: sessionKey, base_url: baseUrl ?? null }, + request: { + session_key: sessionKey, + base_url: baseUrl ?? null, + verify_code: verifyCode ?? null, + }, }); } diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 1efabd1751..ae1d1c0987 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -754,7 +754,10 @@ "botWeixinQrButton": "Show WeChat login QR", "botWeixinQrCancel": "Cancel QR login", "botWeixinAwaitingPhoneConfirm": "Scanned. Please confirm login in WeChat; the pairing code will appear here automatically.", - "botWeixinLinked": "WeChat is linked. If pairing does not start automatically, tap Connect to retry.", + "botWeixinRestriction": "WeChat ClawBot limits the bot to 10 replies within 24 hours after the user sends a message. Split long replies also consume the quota. When the window or quota ends, send another message in WeChat to reactivate it.", + "botWeixinVerifyCodePlaceholder": "Enter the number from WeChat", + "botWeixinVerifyCodeHint": "Enter the number shown in WeChat on your phone to continue connecting.", + "botWeixinVerifyCodeSubmit": "Submit number", "botWeixinPolling": "Waiting for scan…", "botVerboseMode": "Verbose Mode", "botConciseMode": "Concise Mode", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 3d08119f50..6b720c8c5c 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -754,7 +754,10 @@ "botWeixinQrButton": "获取微信登录二维码", "botWeixinQrCancel": "取消扫码", "botWeixinAwaitingPhoneConfirm": "已扫码,请在微信中确认登录;确认后将自动显示配对码。", - "botWeixinLinked": "微信已登录。若未自动进入配对步骤,请点击「连接」重试。", + "botWeixinRestriction": "受微信 ClawBot 通道限制:用户主动发消息后 24 小时内,机器人最多可回复 10 条消息;长回复分段也会占用条数。超时或用完后,请先在微信中再次发送消息以重新激活。", + "botWeixinVerifyCodePlaceholder": "输入微信显示的数字", + "botWeixinVerifyCodeHint": "请填写手机微信中显示的数字,以继续连接。", + "botWeixinVerifyCodeSubmit": "提交数字", "botWeixinPolling": "等待扫码确认…", "botVerboseMode": "详细模式", "botConciseMode": "简洁模式", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 4e97c2c7cf..a2751ac40c 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -754,7 +754,10 @@ "botWeixinQrButton": "獲取微信登錄二維碼", "botWeixinQrCancel": "取消掃碼", "botWeixinAwaitingPhoneConfirm": "已掃碼,請在微信中確認登錄;確認後將自動顯示配對碼。", - "botWeixinLinked": "微信已登錄。若未自動進入配對步驟,請點擊「連接」重試。", + "botWeixinRestriction": "受微信 ClawBot 通道限制:使用者主動發訊息後 24 小時內,機器人最多可回覆 10 則訊息;長回覆分段也會占用則數。逾時或用完後,請先在微信中再次傳送訊息以重新啟用。", + "botWeixinVerifyCodePlaceholder": "輸入微信顯示的數字", + "botWeixinVerifyCodeHint": "請填寫手機微信中顯示的數字,以繼續連接。", + "botWeixinVerifyCodeSubmit": "提交數字", "botWeixinPolling": "等待掃碼確認…", "botVerboseMode": "詳細模式", "botConciseMode": "簡潔模式",