Add Weixin channel with QR login and web setup flow

This commit is contained in:
Coffee
2026-03-25 13:40:03 +08:00
parent 82822d7b25
commit e30d9fe9db
23 changed files with 2578 additions and 16 deletions
+244 -2
View File
@@ -17,9 +17,16 @@ use crate::channels::wasm::{
use crate::channels::{ChannelManager, OutgoingResponse};
use crate::extensions::discovery::OnlineDiscovery;
use crate::extensions::registry::ExtensionRegistry;
use crate::extensions::weixin_login::{
PendingWeixinLogin, WEIXIN_BASE_URL_SETTING_PATH, WEIXIN_CHANNEL_NAME, WEIXIN_DEFAULT_BASE_URL,
WEIXIN_DEFAULT_BOT_TYPE, WeixinLoginPollOutcome,
interactive_login_info as weixin_interactive_login_info, poll_login as poll_weixin_login,
purge_expired_logins as purge_expired_weixin_logins, start_login as start_weixin_login,
};
use crate::extensions::{
ActivateResult, AuthResult, ConfigureResult, ExtensionError, ExtensionKind, ExtensionSource,
InstallResult, InstalledExtension, RegistryEntry, ResultSource, SearchResult, ToolAuthState,
InstallResult, InstalledExtension, InteractiveLoginInfo, InteractiveLoginPollResult,
InteractiveLoginStartResult, RegistryEntry, ResultSource, SearchResult, ToolAuthState,
UpgradeOutcome, UpgradeResult, VerificationChallenge,
};
use crate::hooks::HookRegistry;
@@ -95,6 +102,7 @@ struct ChannelRuntimeState {
pub struct ExtensionSetupSchema {
pub secrets: Vec<crate::channels::web::types::SecretFieldInfo>,
pub fields: Vec<crate::channels::web::types::SetupFieldInfo>,
pub interactive_login: Option<InteractiveLoginInfo>,
}
/// Only these global (non-namespaced) setting paths may be written by extension
@@ -429,6 +437,7 @@ pub struct ExtensionManager {
/// Set by the web gateway at startup via `enable_gateway_mode()`.
gateway_base_url: RwLock<Option<String>>,
pending_telegram_verification: RwLock<HashMap<String, PendingTelegramVerificationChallenge>>,
pending_weixin_logins: RwLock<HashMap<String, PendingWeixinLogin>>,
#[cfg(test)]
test_wasm_channel_loader: RwLock<Option<TestWasmChannelLoader>>,
#[cfg(test)]
@@ -542,6 +551,7 @@ impl ExtensionManager {
gateway_mode: std::sync::atomic::AtomicBool::new(false),
gateway_base_url: RwLock::new(None),
pending_telegram_verification: RwLock::new(HashMap::new()),
pending_weixin_logins: RwLock::new(HashMap::new()),
#[cfg(test)]
test_wasm_channel_loader: RwLock::new(None),
#[cfg(test)]
@@ -780,6 +790,16 @@ impl ExtensionManager {
overrides.insert("bot_username".to_string(), serde_json::json!(username));
}
if name == WEIXIN_CHANNEL_NAME
&& let Some(store) = self.store.as_ref()
&& let Ok(Some(serde_json::Value::String(base_url))) = store
.get_setting(&self.user_id, WEIXIN_BASE_URL_SETTING_PATH)
.await
&& !base_url.trim().is_empty()
{
overrides.insert("base_url".to_string(), serde_json::json!(base_url));
}
overrides
}
@@ -3529,6 +3549,15 @@ impl ExtensionManager {
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmChannel));
}
if name == WEIXIN_CHANNEL_NAME {
return Ok(AuthResult::awaiting_token(
name,
ExtensionKind::WasmChannel,
"Open the Weixin channel setup to scan a QR code and connect it.".to_string(),
cap_file.setup.setup_url.clone(),
));
}
// Prompt for the first missing secret
let secret = &missing[0];
Ok(AuthResult::awaiting_token(
@@ -4499,6 +4528,21 @@ impl ExtensionManager {
}
!expired
});
let mut weixin_logins = self.pending_weixin_logins.write().await;
purge_expired_weixin_logins(&mut weixin_logins);
}
fn interactive_login_info_for_extension(
name: &str,
kind: ExtensionKind,
) -> Option<InteractiveLoginInfo> {
match (kind, name) {
(ExtensionKind::WasmChannel, WEIXIN_CHANNEL_NAME) => {
Some(weixin_interactive_login_info())
}
_ => None,
}
}
/// Get the setup schema for an extension (secret/text fields and their status).
@@ -4518,6 +4562,10 @@ impl ExtensionManager {
return Ok(ExtensionSetupSchema {
secrets: Vec::new(),
fields: Vec::new(),
interactive_login: Self::interactive_login_info_for_extension(
name,
ExtensionKind::WasmChannel,
),
});
}
let cap_bytes = tokio::fs::read(&cap_path)
@@ -4527,6 +4575,14 @@ impl ExtensionManager {
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
if name == WEIXIN_CHANNEL_NAME {
return Ok(ExtensionSetupSchema {
secrets: Vec::new(),
fields: Vec::new(),
interactive_login: Some(weixin_interactive_login_info()),
});
}
let mut secrets = Vec::new();
for secret in &cap_file.setup.required_secrets {
let provided = self
@@ -4547,6 +4603,7 @@ impl ExtensionManager {
Ok(ExtensionSetupSchema {
secrets,
fields: Vec::new(),
interactive_login: None,
})
}
ExtensionKind::WasmTool => {
@@ -4554,6 +4611,7 @@ impl ExtensionManager {
return Ok(ExtensionSetupSchema {
secrets: Vec::new(),
fields: Vec::new(),
interactive_login: None,
});
};
@@ -4593,15 +4651,199 @@ impl ExtensionManager {
});
}
}
Ok(ExtensionSetupSchema { secrets, fields })
Ok(ExtensionSetupSchema {
secrets,
fields,
interactive_login: None,
})
}
_ => Ok(ExtensionSetupSchema {
secrets: Vec::new(),
fields: Vec::new(),
interactive_login: None,
}),
}
}
async fn resolve_weixin_base_url(&self, user_id: &str) -> String {
if let Some(store) = &self.store
&& let Ok(Some(serde_json::Value::String(value))) = store
.get_setting(user_id, WEIXIN_BASE_URL_SETTING_PATH)
.await
{
let trimmed = value.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", WEIXIN_CHANNEL_NAME));
if let Ok(cap_bytes) = tokio::fs::read(&cap_path).await
&& let Ok(cap_file) =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
&& let Some(value) = cap_file
.config
.get("base_url")
.and_then(|value| value.as_str())
{
let trimmed = value.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
WEIXIN_DEFAULT_BASE_URL.to_string()
}
async fn resolve_weixin_bot_type(&self) -> String {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", WEIXIN_CHANNEL_NAME));
if let Ok(cap_bytes) = tokio::fs::read(&cap_path).await
&& let Ok(cap_file) =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
&& let Some(value) = cap_file
.config
.get("bot_type")
.and_then(|value| value.as_str())
{
let trimmed = value.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
WEIXIN_DEFAULT_BOT_TYPE.to_string()
}
pub async fn start_interactive_login(
&self,
name: &str,
user_id: &str,
) -> Result<InteractiveLoginStartResult, ExtensionError> {
Self::validate_extension_name(name)?;
let kind = self.determine_installed_kind(name, user_id).await?;
if Self::interactive_login_info_for_extension(name, kind).is_none() {
return Err(ExtensionError::AuthNotSupported(format!(
"Interactive login is not supported for '{}'",
name
)));
}
if name != WEIXIN_CHANNEL_NAME {
return Err(ExtensionError::AuthNotSupported(format!(
"Interactive login is not implemented for '{}'",
name
)));
}
self.cleanup_expired_auths().await;
let base_url = self.resolve_weixin_base_url(user_id).await;
let bot_type = self.resolve_weixin_bot_type().await;
let (session, result) = start_weixin_login(user_id, &base_url, &bot_type).await?;
self.pending_weixin_logins
.write()
.await
.insert(session.session_id.clone(), session);
Ok(result)
}
pub async fn poll_interactive_login(
&self,
name: &str,
session_id: &str,
user_id: &str,
) -> Result<InteractiveLoginPollResult, ExtensionError> {
Self::validate_extension_name(name)?;
let kind = self.determine_installed_kind(name, user_id).await?;
if Self::interactive_login_info_for_extension(name, kind).is_none() {
return Err(ExtensionError::AuthNotSupported(format!(
"Interactive login is not supported for '{}'",
name
)));
}
if name != WEIXIN_CHANNEL_NAME {
return Err(ExtensionError::AuthNotSupported(format!(
"Interactive login is not implemented for '{}'",
name
)));
}
self.cleanup_expired_auths().await;
let mut sessions = self.pending_weixin_logins.write().await;
let Some(session) = sessions.get_mut(session_id) else {
return Err(ExtensionError::Other(
"This Weixin login session no longer exists. Start again.".to_string(),
));
};
if session.user_id != user_id {
return Err(ExtensionError::AuthFailed(
"This Weixin login session belongs to another user".to_string(),
));
}
let outcome = poll_weixin_login(session).await?;
match outcome {
WeixinLoginPollOutcome::Pending(result) => {
if matches!(result.status.as_str(), "failed") {
sessions.remove(session_id);
}
Ok(result)
}
WeixinLoginPollOutcome::Confirmed(confirmed) => {
sessions.remove(session_id);
drop(sessions);
if let Some(base_url) = confirmed.base_url.as_deref()
&& let Some(store) = &self.store
{
let _ = store
.set_setting(
user_id,
WEIXIN_BASE_URL_SETTING_PATH,
&serde_json::Value::String(base_url.to_string()),
)
.await;
}
let mut secrets = std::collections::HashMap::new();
secrets.insert("weixin_bot_token".to_string(), confirmed.bot_token);
let configure = self
.configure(name, &secrets, &std::collections::HashMap::new(), user_id)
.await?;
Ok(InteractiveLoginPollResult {
session_id: session_id.to_string(),
status: if configure.activated {
"succeeded".to_string()
} else {
"failed".to_string()
},
message: if configure.activated {
format!(
"Weixin connected as {}. {}",
confirmed.ilink_bot_id, configure.message
)
} else {
format!(
"Weixin login succeeded for {} but activation failed: {}",
confirmed.ilink_bot_id, configure.message
)
},
qr_code_url: None,
activated: Some(configure.activated),
})
}
}
}
async fn configure_telegram_binding(
&self,
name: &str,
+47
View File
@@ -19,6 +19,7 @@
pub mod discovery;
pub mod manager;
pub mod registry;
pub(crate) mod weixin_login;
pub use discovery::OnlineDiscovery;
pub use manager::ExtensionManager;
@@ -439,6 +440,52 @@ impl<'de> Deserialize<'de> for AuthResult {
}
}
/// Interactive login metadata surfaced to setup UIs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractiveLoginInfo {
/// Login method identifier (for example `qr_code`).
pub method: String,
/// User-facing button label.
pub button_label: String,
/// Optional short instructions shown above the login control.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
/// Result of starting an interactive extension login flow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractiveLoginStartResult {
/// Opaque session identifier used by follow-up poll requests.
pub session_id: String,
/// Flow status (`pending`, `error`).
pub status: String,
/// Human-readable message for the UI.
pub message: String,
/// Optional QR/image URL for browser display.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qr_code_url: Option<String>,
/// Optional short instructions shown alongside the QR code.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
/// Result of polling an interactive extension login flow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractiveLoginPollResult {
/// Session identifier associated with this poll result.
pub session_id: String,
/// Flow status (`pending`, `scanned`, `refreshed`, `succeeded`, `failed`).
pub status: String,
/// Human-readable message for the UI.
pub message: String,
/// Optional refreshed QR/image URL.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qr_code_url: Option<String>,
/// Whether the extension was successfully activated as part of login completion.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activated: Option<bool>,
}
/// Result of activating an extension.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivateResult {
+446
View File
@@ -0,0 +1,446 @@
use std::time::{Duration, Instant};
use reqwest::Client;
use serde::Deserialize;
use uuid::Uuid;
use crate::extensions::{
ExtensionError, InteractiveLoginInfo, InteractiveLoginPollResult, InteractiveLoginStartResult,
};
pub(crate) const WEIXIN_CHANNEL_NAME: &str = "weixin";
pub(crate) const WEIXIN_BASE_URL_SETTING_PATH: &str = "extensions.weixin.base_url";
pub(crate) const WEIXIN_DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
pub(crate) const WEIXIN_DEFAULT_BOT_TYPE: &str = "3";
const LOGIN_SESSION_TTL: Duration = Duration::from_secs(5 * 60);
const QR_LONG_POLL_TIMEOUT: Duration = Duration::from_secs(35);
const QR_FETCH_TIMEOUT: Duration = Duration::from_secs(15);
const MAX_QR_REFRESH_COUNT: u8 = 3;
#[derive(Debug, Clone)]
pub(crate) struct PendingWeixinLogin {
pub user_id: String,
pub session_id: String,
pub qrcode: String,
pub qr_code_url: String,
pub started_at: Instant,
pub base_url: String,
pub bot_type: String,
pub refresh_count: u8,
}
impl PendingWeixinLogin {
pub fn is_fresh(&self) -> bool {
self.started_at.elapsed() < LOGIN_SESSION_TTL
}
}
#[derive(Debug, Clone)]
pub(crate) struct ConfirmedWeixinLogin {
pub bot_token: String,
pub base_url: Option<String>,
pub ilink_bot_id: String,
}
pub(crate) enum WeixinLoginPollOutcome {
Pending(InteractiveLoginPollResult),
Confirmed(ConfirmedWeixinLogin),
}
#[derive(Debug, Clone, Deserialize)]
struct QrCodeResponse {
qrcode: String,
qrcode_img_content: String,
}
#[derive(Debug, Clone, Deserialize)]
struct QrStatusResponse {
status: String,
#[serde(default)]
bot_token: Option<String>,
#[serde(default)]
ilink_bot_id: Option<String>,
#[serde(default)]
baseurl: Option<String>,
}
pub(crate) fn interactive_login_info() -> InteractiveLoginInfo {
InteractiveLoginInfo {
method: "qr_code".to_string(),
button_label: "Connect Weixin".to_string(),
instructions: Some("Scan the QR code with Weixin to connect this channel.".to_string()),
}
}
pub(crate) fn purge_expired_logins(
sessions: &mut std::collections::HashMap<String, PendingWeixinLogin>,
) {
sessions.retain(|_, session| session.is_fresh());
}
pub(crate) async fn start_login(
user_id: &str,
base_url: &str,
bot_type: &str,
) -> Result<(PendingWeixinLogin, InteractiveLoginStartResult), ExtensionError> {
let qr = fetch_qr_code(base_url, bot_type).await?;
Ok(build_pending_login(user_id, base_url, bot_type, qr))
}
pub(crate) async fn poll_login(
session: &mut PendingWeixinLogin,
) -> Result<WeixinLoginPollOutcome, ExtensionError> {
if !session.is_fresh() {
return Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "failed".to_string(),
message: "The QR code expired. Start a new Weixin connection.".to_string(),
qr_code_url: None,
activated: Some(false),
},
));
}
let status = poll_qr_status(&session.base_url, &session.qrcode).await?;
let refreshed_qr = if status.status == "expired" && session.refresh_count < MAX_QR_REFRESH_COUNT
{
Some(fetch_qr_code(&session.base_url, &session.bot_type).await?)
} else {
None
};
handle_poll_status(session, status, refreshed_qr)
}
fn build_pending_login(
user_id: &str,
base_url: &str,
bot_type: &str,
qr: QrCodeResponse,
) -> (PendingWeixinLogin, InteractiveLoginStartResult) {
let session_id = Uuid::new_v4().to_string();
let session = PendingWeixinLogin {
user_id: user_id.to_string(),
session_id: session_id.clone(),
qrcode: qr.qrcode,
qr_code_url: qr.qrcode_img_content.clone(),
started_at: Instant::now(),
base_url: base_url.to_string(),
bot_type: bot_type.to_string(),
refresh_count: 0,
};
let result = InteractiveLoginStartResult {
session_id,
status: "pending".to_string(),
message: "Scan the QR code in Weixin to finish connecting.".to_string(),
qr_code_url: Some(qr.qrcode_img_content),
instructions: Some(
"Keep this window open while you scan and confirm on your phone.".to_string(),
),
};
(session, result)
}
fn handle_poll_status(
session: &mut PendingWeixinLogin,
status: QrStatusResponse,
refreshed_qr: Option<QrCodeResponse>,
) -> Result<WeixinLoginPollOutcome, ExtensionError> {
match status.status.as_str() {
"wait" => Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "pending".to_string(),
message: "Waiting for the QR code to be scanned.".to_string(),
qr_code_url: None,
activated: None,
},
)),
"scaned" => Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "scanned".to_string(),
message: "QR code scanned. Confirm the login in Weixin.".to_string(),
qr_code_url: None,
activated: None,
},
)),
"expired" => {
session.refresh_count = session.refresh_count.saturating_add(1);
if session.refresh_count > MAX_QR_REFRESH_COUNT {
return Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "failed".to_string(),
message: "The QR code expired too many times. Start again.".to_string(),
qr_code_url: None,
activated: Some(false),
},
));
}
let refreshed = refreshed_qr.ok_or_else(|| {
ExtensionError::Other(
"Weixin QR status expired without a refreshed QR code".to_string(),
)
})?;
session.qrcode = refreshed.qrcode;
session.qr_code_url = refreshed.qrcode_img_content.clone();
session.started_at = Instant::now();
Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "refreshed".to_string(),
message: "The QR code expired, so a fresh one was generated.".to_string(),
qr_code_url: Some(refreshed.qrcode_img_content),
activated: None,
},
))
}
"confirmed" => {
let bot_token = status.bot_token.filter(|token| !token.trim().is_empty());
let ilink_bot_id = status
.ilink_bot_id
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
ExtensionError::Other(
"Weixin login succeeded but no bot account id was returned".to_string(),
)
})?;
let bot_token = bot_token.ok_or_else(|| {
ExtensionError::Other(
"Weixin login succeeded but no bot token was returned".to_string(),
)
})?;
Ok(WeixinLoginPollOutcome::Confirmed(ConfirmedWeixinLogin {
bot_token,
base_url: status.baseurl.filter(|value| !value.trim().is_empty()),
ilink_bot_id,
}))
}
other => {
tracing::warn!(status = other, "Unexpected Weixin QR status");
Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "failed".to_string(),
message: format!("Unexpected Weixin login status: {other}"),
qr_code_url: None,
activated: Some(false),
},
))
}
}
}
fn ensure_trailing_slash(base_url: &str) -> String {
if base_url.ends_with('/') {
base_url.to_string()
} else {
format!("{base_url}/")
}
}
async fn fetch_qr_code(base_url: &str, bot_type: &str) -> Result<QrCodeResponse, ExtensionError> {
let base = ensure_trailing_slash(base_url);
let url = format!(
"{base}ilink/bot/get_bot_qrcode?bot_type={}",
urlencoding::encode(bot_type)
);
let client = Client::builder()
.timeout(QR_FETCH_TIMEOUT)
.build()
.map_err(|e| ExtensionError::Other(format!("Failed to create Weixin login client: {e}")))?;
let response = client
.get(&url)
.send()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to fetch Weixin QR code: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(status = %status, "Weixin QR code request failed");
return Err(ExtensionError::Other(format!(
"Weixin QR code request failed with {status}: {body}"
)));
}
response
.json::<QrCodeResponse>()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to parse Weixin QR code response: {e}")))
}
async fn poll_qr_status(base_url: &str, qrcode: &str) -> Result<QrStatusResponse, ExtensionError> {
let base = ensure_trailing_slash(base_url);
let url = format!(
"{base}ilink/bot/get_qrcode_status?qrcode={}",
urlencoding::encode(qrcode)
);
let client = Client::builder()
.timeout(QR_LONG_POLL_TIMEOUT)
.build()
.map_err(|e| ExtensionError::Other(format!("Failed to create Weixin poll client: {e}")))?;
let response = client
.get(&url)
.header("iLink-App-ClientVersion", "1")
.send()
.await;
let response = match response {
Ok(response) => response,
Err(error) if error.is_timeout() => {
return Ok(QrStatusResponse {
status: "wait".to_string(),
bot_token: None,
ilink_bot_id: None,
baseurl: None,
});
}
Err(error) => {
return Err(ExtensionError::Other(format!(
"Failed to poll Weixin QR status: {error}"
)));
}
};
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(status = %status, "Weixin QR status poll failed");
return Err(ExtensionError::Other(format!(
"Weixin QR status poll failed with {status}: {body}"
)));
}
response
.json::<QrStatusResponse>()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to parse Weixin QR status: {e}")))
}
#[cfg(test)]
mod tests {
use super::{
QrCodeResponse, QrStatusResponse, WeixinLoginPollOutcome, build_pending_login,
handle_poll_status,
};
#[test]
fn test_build_pending_login_returns_qr_state_and_result() {
let (session, start_result) = build_pending_login(
"owner",
"https://ilink.example",
"3",
QrCodeResponse {
qrcode: "qr-123".to_string(),
qrcode_img_content: "https://qr.example/one".to_string(),
},
);
assert_eq!(session.user_id, "owner");
assert_eq!(session.base_url, "https://ilink.example");
assert_eq!(session.bot_type, "3");
assert_eq!(session.qrcode, "qr-123");
assert_eq!(session.qr_code_url, "https://qr.example/one");
assert_eq!(start_result.status, "pending");
assert_eq!(
start_result.qr_code_url.as_deref(),
Some("https://qr.example/one")
);
assert_eq!(start_result.session_id, session.session_id);
}
#[test]
fn test_handle_poll_status_confirms_login() -> Result<(), String> {
let (mut session, _) = build_pending_login(
"owner",
"https://ilink.example",
"3",
QrCodeResponse {
qrcode: "qr-123".to_string(),
qrcode_img_content: "https://qr.example/one".to_string(),
},
);
let outcome = handle_poll_status(
&mut session,
QrStatusResponse {
status: "confirmed".to_string(),
bot_token: Some("bot-token-123".to_string()),
ilink_bot_id: Some("wx-bot-1".to_string()),
baseurl: Some("https://override.example".to_string()),
},
None,
)
.map_err(|e| e.to_string())?;
match outcome {
WeixinLoginPollOutcome::Confirmed(confirmed) => {
assert_eq!(confirmed.bot_token, "bot-token-123");
assert_eq!(confirmed.ilink_bot_id, "wx-bot-1");
assert_eq!(
confirmed.base_url.as_deref(),
Some("https://override.example")
);
Ok(())
}
WeixinLoginPollOutcome::Pending(result) => Err(format!(
"expected confirmed login, got pending status {}",
result.status
)),
}
}
#[test]
fn test_handle_poll_status_refreshes_expired_qr() -> Result<(), String> {
let (mut session, _) = build_pending_login(
"owner",
"https://ilink.example",
"3",
QrCodeResponse {
qrcode: "qr-initial".to_string(),
qrcode_img_content: "https://qr.example/initial".to_string(),
},
);
let outcome = handle_poll_status(
&mut session,
QrStatusResponse {
status: "expired".to_string(),
bot_token: None,
ilink_bot_id: None,
baseurl: None,
},
Some(QrCodeResponse {
qrcode: "qr-refreshed".to_string(),
qrcode_img_content: "https://qr.example/refreshed".to_string(),
}),
)
.map_err(|e| e.to_string())?;
match outcome {
WeixinLoginPollOutcome::Pending(result) => {
assert_eq!(result.status, "refreshed");
assert_eq!(
result.qr_code_url.as_deref(),
Some("https://qr.example/refreshed")
);
assert_eq!(session.qrcode, "qr-refreshed");
assert_eq!(session.refresh_count, 1);
Ok(())
}
WeixinLoginPollOutcome::Confirmed(_) => {
Err("expected QR refresh before confirmation".to_string())
}
}
}
}