mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-02 01:29:23 +00:00
444 lines
15 KiB
Rust
444 lines
15 KiB
Rust
use std::time::{Duration, Instant};
|
|
|
|
use reqwest::Client;
|
|
use serde::Deserialize;
|
|
use uuid::Uuid;
|
|
|
|
use crate::extensions::{
|
|
ExtensionError, InteractiveLoginInfo, InteractiveLoginPollResult, InteractiveLoginStartResult,
|
|
};
|
|
|
|
pub(crate) const WECHAT_CHANNEL_NAME: &str = "wechat";
|
|
pub(crate) const WECHAT_BASE_URL_SETTING_PATH: &str = "extensions.wechat.base_url";
|
|
pub(crate) const WECHAT_DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
|
|
pub(crate) const WECHAT_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 PendingWechatLogin {
|
|
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 PendingWechatLogin {
|
|
pub fn is_fresh(&self) -> bool {
|
|
self.started_at.elapsed() < LOGIN_SESSION_TTL
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct ConfirmedWechatLogin {
|
|
pub bot_token: String,
|
|
pub base_url: Option<String>,
|
|
pub ilink_bot_id: String,
|
|
}
|
|
|
|
pub(crate) enum WechatLoginPollOutcome {
|
|
Pending(InteractiveLoginPollResult),
|
|
Confirmed(ConfirmedWechatLogin),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct QrCodeResponse {
|
|
qrcode: String,
|
|
qrcode_img_content: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct QrStatusResponse {
|
|
status: String,
|
|
bot_token: Option<String>,
|
|
ilink_bot_id: Option<String>,
|
|
baseurl: Option<String>,
|
|
}
|
|
|
|
pub(crate) fn interactive_login_info() -> InteractiveLoginInfo {
|
|
InteractiveLoginInfo {
|
|
method: "qr_code".to_string(),
|
|
button_label: "Connect WeChat".to_string(),
|
|
instructions: Some("Scan the QR code with WeChat to connect this channel.".to_string()),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn purge_expired_logins(
|
|
sessions: &mut std::collections::HashMap<String, PendingWechatLogin>,
|
|
) {
|
|
sessions.retain(|_, session| session.is_fresh());
|
|
}
|
|
|
|
pub(crate) async fn start_login(
|
|
user_id: &str,
|
|
base_url: &str,
|
|
bot_type: &str,
|
|
) -> Result<(PendingWechatLogin, 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 PendingWechatLogin,
|
|
) -> Result<WechatLoginPollOutcome, ExtensionError> {
|
|
if !session.is_fresh() {
|
|
return Ok(WechatLoginPollOutcome::Pending(
|
|
InteractiveLoginPollResult {
|
|
session_id: session.session_id.clone(),
|
|
status: "failed".to_string(),
|
|
message: "The QR code expired. Start a new WeChat 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,
|
|
) -> (PendingWechatLogin, InteractiveLoginStartResult) {
|
|
let session_id = Uuid::new_v4().to_string();
|
|
let session = PendingWechatLogin {
|
|
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: "Open the WeChat QR page to continue.".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 PendingWechatLogin,
|
|
status: QrStatusResponse,
|
|
refreshed_qr: Option<QrCodeResponse>,
|
|
) -> Result<WechatLoginPollOutcome, ExtensionError> {
|
|
match status.status.as_str() {
|
|
"wait" => Ok(WechatLoginPollOutcome::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(WechatLoginPollOutcome::Pending(
|
|
InteractiveLoginPollResult {
|
|
session_id: session.session_id.clone(),
|
|
status: "scanned".to_string(),
|
|
message: "QR code scanned. Confirm the login in WeChat.".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(WechatLoginPollOutcome::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(
|
|
"WeChat 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(WechatLoginPollOutcome::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(
|
|
"WeChat login succeeded but no bot account id was returned".to_string(),
|
|
)
|
|
})?;
|
|
|
|
let bot_token = bot_token.ok_or_else(|| {
|
|
ExtensionError::Other(
|
|
"WeChat login succeeded but no bot token was returned".to_string(),
|
|
)
|
|
})?;
|
|
|
|
Ok(WechatLoginPollOutcome::Confirmed(ConfirmedWechatLogin {
|
|
bot_token,
|
|
base_url: status.baseurl.filter(|value| !value.trim().is_empty()),
|
|
ilink_bot_id,
|
|
}))
|
|
}
|
|
other => {
|
|
tracing::warn!(status = other, "Unexpected WeChat QR status");
|
|
Ok(WechatLoginPollOutcome::Pending(
|
|
InteractiveLoginPollResult {
|
|
session_id: session.session_id.clone(),
|
|
status: "failed".to_string(),
|
|
message: format!("Unexpected WeChat 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 WeChat login client: {e}")))?;
|
|
|
|
let response = client
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ExtensionError::Other(format!("Failed to fetch WeChat QR code: {e}")))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
tracing::warn!(status = %status, "WeChat QR code request failed");
|
|
return Err(ExtensionError::Other(format!(
|
|
"WeChat QR code request failed with {status}: {body}"
|
|
)));
|
|
}
|
|
|
|
response
|
|
.json::<QrCodeResponse>()
|
|
.await
|
|
.map_err(|e| ExtensionError::Other(format!("Failed to parse WeChat 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 WeChat 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 WeChat QR status: {error}"
|
|
)));
|
|
}
|
|
};
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
tracing::warn!(status = %status, "WeChat QR status poll failed");
|
|
return Err(ExtensionError::Other(format!(
|
|
"WeChat QR status poll failed with {status}: {body}"
|
|
)));
|
|
}
|
|
|
|
response
|
|
.json::<QrStatusResponse>()
|
|
.await
|
|
.map_err(|e| ExtensionError::Other(format!("Failed to parse WeChat QR status: {e}")))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
QrCodeResponse, QrStatusResponse, WechatLoginPollOutcome, 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 {
|
|
WechatLoginPollOutcome::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(())
|
|
}
|
|
WechatLoginPollOutcome::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 {
|
|
WechatLoginPollOutcome::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(())
|
|
}
|
|
WechatLoginPollOutcome::Confirmed(_) => {
|
|
Err("expected QR refresh before confirmation".to_string())
|
|
}
|
|
}
|
|
}
|
|
}
|