mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
Add Weixin channel with QR login and web setup flow
This commit is contained in:
+244
-2
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user