mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 08:17:53 +00:00
Add Weixin channel with QR login and web setup flow
This commit is contained in:
+107
-2
@@ -190,7 +190,20 @@ async fn register_channel(
|
||||
// The credential injection system only replaces placeholders in URLs
|
||||
// and headers, so channels like Feishu that exchange app_id + app_secret
|
||||
// for a tenant token need the raw values in their config.
|
||||
inject_channel_secrets_into_config(&channel_name, secrets_store, &mut config_updates).await;
|
||||
inject_channel_secrets_into_config(
|
||||
&channel_name,
|
||||
&config.owner_id,
|
||||
secrets_store,
|
||||
&mut config_updates,
|
||||
)
|
||||
.await;
|
||||
inject_channel_settings_into_config(
|
||||
&channel_name,
|
||||
&config.owner_id,
|
||||
settings_store,
|
||||
&mut config_updates,
|
||||
)
|
||||
.await;
|
||||
|
||||
if !config_updates.is_empty() {
|
||||
channel_arc.update_config(config_updates).await;
|
||||
@@ -396,6 +409,7 @@ pub async fn inject_channel_credentials(
|
||||
/// `feishu_app_secret` are injected as config keys `app_id` and `app_secret`.
|
||||
async fn inject_channel_secrets_into_config(
|
||||
channel_name: &str,
|
||||
owner_id: &str,
|
||||
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
@@ -413,7 +427,7 @@ async fn inject_channel_secrets_into_config(
|
||||
};
|
||||
|
||||
for &(config_key, secret_name) in secret_config_mappings {
|
||||
match secrets.get_decrypted("default", secret_name).await {
|
||||
match secrets.get_decrypted(owner_id, secret_name).await {
|
||||
Ok(decrypted) => {
|
||||
config_updates.insert(
|
||||
config_key.to_string(),
|
||||
@@ -442,3 +456,94 @@ async fn inject_channel_secrets_into_config(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject channel-specific settings into config for channels that persist
|
||||
/// runtime-discovered values (for example a custom API base URL after login).
|
||||
async fn inject_channel_settings_into_config(
|
||||
channel_name: &str,
|
||||
owner_id: &str,
|
||||
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
|
||||
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
let Some(store) = settings_store else {
|
||||
return;
|
||||
};
|
||||
|
||||
let setting_mappings: &[(&str, &str)] = match channel_name {
|
||||
"weixin" => &[("base_url", "extensions.weixin.base_url")],
|
||||
_ => return,
|
||||
};
|
||||
|
||||
for &(config_key, setting_path) in setting_mappings {
|
||||
if let Ok(Some(serde_json::Value::String(value))) =
|
||||
store.get_setting(owner_id, setting_path).await
|
||||
{
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
config_updates.insert(
|
||||
config_key.to_string(),
|
||||
serde_json::Value::String(trimmed.to_string()),
|
||||
);
|
||||
tracing::debug!(
|
||||
channel = %channel_name,
|
||||
config_key = %config_key,
|
||||
setting_path = %setting_path,
|
||||
"Injected setting into channel config"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::{Database, SettingsStore};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_inject_channel_settings_uses_owner_scope() -> Result<(), String> {
|
||||
let dir = tempfile::tempdir().map_err(|e| format!("tempdir failed: {e}"))?;
|
||||
let db_path = dir.path().join("weixin-settings.db");
|
||||
let db = Arc::new(
|
||||
crate::db::libsql::LibSqlBackend::new_local(&db_path)
|
||||
.await
|
||||
.map_err(|e| format!("create local libsql backend failed: {e}"))?,
|
||||
);
|
||||
db.run_migrations()
|
||||
.await
|
||||
.map_err(|e| format!("run libsql migrations failed: {e}"))?;
|
||||
|
||||
db.set_setting(
|
||||
"default",
|
||||
"extensions.weixin.base_url",
|
||||
&serde_json::json!("https://default.example"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("persist default setting failed: {e}"))?;
|
||||
db.set_setting(
|
||||
"owner-123",
|
||||
"extensions.weixin.base_url",
|
||||
&serde_json::json!("https://owner.example"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("persist owner setting failed: {e}"))?;
|
||||
|
||||
let settings_store: Arc<dyn crate::db::SettingsStore> = db;
|
||||
let mut config_updates = std::collections::HashMap::new();
|
||||
super::inject_channel_settings_into_config(
|
||||
"weixin",
|
||||
"owner-123",
|
||||
Some(&settings_store),
|
||||
&mut config_updates,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
config_updates.get("base_url"),
|
||||
Some(&serde_json::json!("https://owner.example"))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,6 +469,14 @@ pub async fn start_server(
|
||||
"/api/extensions/{name}/setup",
|
||||
get(extensions_setup_handler).post(extensions_setup_submit_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/extensions/{name}/login/start",
|
||||
post(extensions_login_start_handler),
|
||||
)
|
||||
.route(
|
||||
"/api/extensions/{name}/login/poll",
|
||||
post(extensions_login_poll_handler),
|
||||
)
|
||||
// Pairing
|
||||
.route("/api/pairing/{channel}", get(pairing_list_handler))
|
||||
.route(
|
||||
@@ -2437,9 +2445,93 @@ async fn extensions_setup_handler(
|
||||
kind,
|
||||
secrets: setup.secrets,
|
||||
fields: setup.fields,
|
||||
interactive_login: setup.interactive_login,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn extensions_login_start_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(name): Path<String>,
|
||||
Json(_req): Json<ExtensionInteractiveLoginStartRequest>,
|
||||
) -> Result<Json<ExtensionInteractiveLoginResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Extension manager not available (secrets store required)".to_string(),
|
||||
))?;
|
||||
|
||||
match ext_mgr.start_interactive_login(&name, &user.user_id).await {
|
||||
Ok(result) => Ok(Json(ExtensionInteractiveLoginResponse {
|
||||
success: true,
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
session_id: Some(result.session_id),
|
||||
qr_code_url: result.qr_code_url,
|
||||
instructions: result.instructions,
|
||||
activated: None,
|
||||
})),
|
||||
Err(e) => Ok(Json(ExtensionInteractiveLoginResponse {
|
||||
success: false,
|
||||
status: "failed".to_string(),
|
||||
message: e.to_string(),
|
||||
session_id: None,
|
||||
qr_code_url: None,
|
||||
instructions: None,
|
||||
activated: Some(false),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn extensions_login_poll_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
Path(name): Path<String>,
|
||||
Json(req): Json<ExtensionInteractiveLoginPollRequest>,
|
||||
) -> Result<Json<ExtensionInteractiveLoginResponse>, (StatusCode, String)> {
|
||||
let ext_mgr = state.extension_manager.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Extension manager not available (secrets store required)".to_string(),
|
||||
))?;
|
||||
|
||||
match ext_mgr
|
||||
.poll_interactive_login(&name, &req.session_id, &user.user_id)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if result.activated == Some(true) {
|
||||
clear_auth_mode(&state, &user.user_id).await;
|
||||
state.sse.broadcast_for_user(
|
||||
&user.user_id,
|
||||
SseEvent::AuthCompleted {
|
||||
extension_name: name.clone(),
|
||||
success: true,
|
||||
message: result.message.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Json(ExtensionInteractiveLoginResponse {
|
||||
success: result.status != "failed",
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
session_id: Some(result.session_id),
|
||||
qr_code_url: result.qr_code_url,
|
||||
instructions: None,
|
||||
activated: result.activated,
|
||||
}))
|
||||
}
|
||||
Err(e) => Ok(Json(ExtensionInteractiveLoginResponse {
|
||||
success: false,
|
||||
status: "failed".to_string(),
|
||||
message: e.to_string(),
|
||||
session_id: Some(req.session_id),
|
||||
qr_code_url: None,
|
||||
instructions: None,
|
||||
activated: Some(false),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn extensions_setup_submit_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
AuthenticatedUser(user): AuthenticatedUser,
|
||||
@@ -3003,6 +3095,69 @@ mod tests {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extensions_setup_returns_interactive_login_for_weixin() {
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let secrets = test_secrets_store();
|
||||
let (ext_mgr, _wasm_tools_dir, wasm_channels_dir) = test_ext_mgr(secrets);
|
||||
|
||||
std::fs::write(wasm_channels_dir.path().join("weixin.wasm"), b"\0asm fake")
|
||||
.expect("write fake weixin wasm");
|
||||
let caps = serde_json::json!({
|
||||
"type": "channel",
|
||||
"name": "weixin",
|
||||
"setup": {
|
||||
"required_secrets": [
|
||||
{"name": "weixin_bot_token", "prompt": "Connect Weixin"}
|
||||
]
|
||||
}
|
||||
});
|
||||
std::fs::write(
|
||||
wasm_channels_dir.path().join("weixin.capabilities.json"),
|
||||
serde_json::to_string(&caps).expect("serialize weixin caps"),
|
||||
)
|
||||
.expect("write weixin capabilities");
|
||||
|
||||
let state = test_gateway_state(Some(ext_mgr));
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/extensions/{name}/setup",
|
||||
get(extensions_setup_handler),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let mut req = axum::http::Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/extensions/weixin/setup")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
req.extensions_mut().insert(UserIdentity {
|
||||
user_id: "test".to_string(),
|
||||
workspace_read_scopes: Vec::new(),
|
||||
});
|
||||
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||
.await
|
||||
.expect("body");
|
||||
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json response");
|
||||
|
||||
assert_eq!(parsed["name"], "weixin");
|
||||
assert_eq!(parsed["interactive_login"]["method"], "qr_code");
|
||||
assert_eq!(
|
||||
parsed["interactive_login"]["button_label"],
|
||||
"Connect Weixin"
|
||||
);
|
||||
assert_eq!(parsed["secrets"], serde_json::json!([]));
|
||||
assert_eq!(parsed["fields"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extensions_setup_submit_returns_failure_when_not_activated() {
|
||||
use axum::body::Body;
|
||||
|
||||
+196
-10
@@ -3056,16 +3056,17 @@ function showConfigureModal(name) {
|
||||
.then((setup) => {
|
||||
const secrets = Array.isArray(setup.secrets) ? setup.secrets : [];
|
||||
const setupFields = Array.isArray(setup.fields) ? setup.fields : [];
|
||||
if (secrets.length === 0 && setupFields.length === 0) {
|
||||
const interactiveLogin = setup.interactive_login || null;
|
||||
if (secrets.length === 0 && setupFields.length === 0 && !interactiveLogin) {
|
||||
showToast('No configuration needed for ' + name, 'info');
|
||||
return;
|
||||
}
|
||||
renderConfigureModal(name, secrets, setupFields);
|
||||
renderConfigureModal(name, secrets, setupFields, interactiveLogin);
|
||||
})
|
||||
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
|
||||
}
|
||||
|
||||
function renderConfigureModal(name, secrets, setupFields) {
|
||||
function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
|
||||
closeConfigureModal();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'configure-overlay';
|
||||
@@ -3091,6 +3092,13 @@ function renderConfigureModal(name, secrets, setupFields) {
|
||||
modal.appendChild(hint);
|
||||
}
|
||||
|
||||
if (interactiveLogin && interactiveLogin.instructions) {
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'configure-hint';
|
||||
hint.textContent = interactiveLogin.instructions;
|
||||
modal.appendChild(hint);
|
||||
}
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'configure-form';
|
||||
|
||||
@@ -3180,7 +3188,13 @@ function renderConfigureModal(name, secrets, setupFields) {
|
||||
fields.push({ kind: 'field', name: setupField.name, input: input });
|
||||
}
|
||||
|
||||
modal.appendChild(form);
|
||||
if (fields.length > 0) {
|
||||
modal.appendChild(form);
|
||||
}
|
||||
|
||||
if (interactiveLogin) {
|
||||
modal.appendChild(renderInteractiveLoginPanel());
|
||||
}
|
||||
|
||||
const error = document.createElement('div');
|
||||
error.className = 'configure-inline-error';
|
||||
@@ -3195,11 +3209,22 @@ function renderConfigureModal(name, secrets, setupFields) {
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'configure-actions';
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.className = 'btn-ext activate';
|
||||
submitBtn.textContent = I18n.t('config.save');
|
||||
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
|
||||
actions.appendChild(submitBtn);
|
||||
if (fields.length > 0) {
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.className = 'btn-ext activate';
|
||||
submitBtn.textContent = I18n.t('config.save');
|
||||
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
|
||||
actions.appendChild(submitBtn);
|
||||
}
|
||||
|
||||
if (interactiveLogin) {
|
||||
const loginBtn = document.createElement('button');
|
||||
loginBtn.className = 'btn-ext activate';
|
||||
loginBtn.textContent = interactiveLogin.button_label || 'Connect';
|
||||
loginBtn.dataset.interactiveLogin = 'true';
|
||||
loginBtn.addEventListener('click', () => startInteractiveLogin(name, overlay));
|
||||
actions.appendChild(loginBtn);
|
||||
}
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn-ext remove';
|
||||
@@ -3211,7 +3236,168 @@ function renderConfigureModal(name, secrets, setupFields) {
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
if (fields.length > 0) fields[0].input.focus();
|
||||
if (fields.length > 0) {
|
||||
fields[0].input.focus();
|
||||
} else {
|
||||
const loginBtn = overlay.querySelector('.configure-actions button[data-interactive-login="true"]');
|
||||
if (loginBtn) loginBtn.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function renderInteractiveLoginPanel() {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'configure-qr-login';
|
||||
panel.style.display = 'none';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'configure-verification-title';
|
||||
title.textContent = 'Weixin QR Login';
|
||||
panel.appendChild(title);
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'configure-verification-instructions';
|
||||
status.textContent = 'Generate a QR code to connect this channel.';
|
||||
status.dataset.qrStatus = 'true';
|
||||
panel.appendChild(status);
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.className = 'configure-qr-image';
|
||||
img.alt = 'Weixin QR code';
|
||||
img.style.display = 'none';
|
||||
img.dataset.qrImage = 'true';
|
||||
panel.appendChild(img);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.className = 'configure-verification-link';
|
||||
link.textContent = 'Open QR code in a new tab';
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer noopener';
|
||||
link.style.display = 'none';
|
||||
link.dataset.qrLink = 'true';
|
||||
panel.appendChild(link);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
function getInteractiveLoginButton(overlay) {
|
||||
return overlay && overlay.querySelector('.configure-actions button[data-interactive-login="true"]');
|
||||
}
|
||||
|
||||
function getInteractiveLoginPanel(overlay) {
|
||||
return overlay && overlay.querySelector('.configure-qr-login');
|
||||
}
|
||||
|
||||
function updateInteractiveLoginPanel(overlay, res) {
|
||||
const panel = getInteractiveLoginPanel(overlay);
|
||||
if (!panel) return;
|
||||
const status = panel.querySelector('[data-qr-status="true"]');
|
||||
const img = panel.querySelector('[data-qr-image="true"]');
|
||||
const link = panel.querySelector('[data-qr-link="true"]');
|
||||
|
||||
panel.style.display = '';
|
||||
if (status) {
|
||||
status.textContent = res.message || '';
|
||||
}
|
||||
|
||||
if (img && res.qr_code_url) {
|
||||
img.src = res.qr_code_url;
|
||||
img.style.display = '';
|
||||
}
|
||||
|
||||
if (link && res.qr_code_url) {
|
||||
link.href = res.qr_code_url;
|
||||
link.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
function setInteractiveLoginBusy(overlay, busy, label) {
|
||||
const loginBtn = getInteractiveLoginButton(overlay);
|
||||
if (!loginBtn) return;
|
||||
loginBtn.disabled = !!busy;
|
||||
if (label) {
|
||||
loginBtn.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
function startInteractiveLogin(name, overlay) {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
clearConfigureInlineError(overlay);
|
||||
setConfigureInlineStatus(overlay, 'Generating Weixin QR code...');
|
||||
setInteractiveLoginBusy(overlay, true, 'Waiting for scan...');
|
||||
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/start', {
|
||||
method: 'POST',
|
||||
body: { force: true },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
if (!res.success || !res.session_id) {
|
||||
setInteractiveLoginBusy(overlay, false, 'Connect Weixin');
|
||||
setConfigureInlineError(overlay, res.message || 'Failed to start interactive login');
|
||||
setConfigureInlineStatus(overlay, '');
|
||||
return;
|
||||
}
|
||||
|
||||
overlay.dataset.interactiveLoginSessionId = res.session_id;
|
||||
updateInteractiveLoginPanel(overlay, res);
|
||||
setConfigureInlineStatus(overlay, res.message || '');
|
||||
pollInteractiveLogin(name, overlay, res.session_id);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
setInteractiveLoginBusy(overlay, false, 'Connect Weixin');
|
||||
setConfigureInlineError(overlay, err.message || 'Failed to start interactive login');
|
||||
setConfigureInlineStatus(overlay, '');
|
||||
});
|
||||
}
|
||||
|
||||
function pollInteractiveLogin(name, overlay, sessionId) {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
|
||||
|
||||
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/poll', {
|
||||
method: 'POST',
|
||||
body: { session_id: sessionId },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
|
||||
|
||||
if (res.qr_code_url) {
|
||||
updateInteractiveLoginPanel(overlay, res);
|
||||
}
|
||||
if (res.message) {
|
||||
setConfigureInlineStatus(overlay, res.message);
|
||||
}
|
||||
|
||||
if (res.status === 'pending' || res.status === 'scanned' || res.status === 'refreshed') {
|
||||
if (res.status === 'refreshed') {
|
||||
setInteractiveLoginBusy(overlay, true, 'Waiting for scan...');
|
||||
}
|
||||
window.setTimeout(function() {
|
||||
pollInteractiveLogin(name, overlay, sessionId);
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.success && res.activated) {
|
||||
closeConfigureModal(name);
|
||||
showToast(res.message || (name + ' connected successfully'), 'success');
|
||||
refreshCurrentSettingsTab();
|
||||
return;
|
||||
}
|
||||
|
||||
setInteractiveLoginBusy(overlay, false, 'Connect Weixin');
|
||||
setConfigureInlineError(overlay, res.message || 'Interactive login failed');
|
||||
setConfigureInlineStatus(overlay, '');
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!overlay || !document.body.contains(overlay)) return;
|
||||
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
|
||||
setInteractiveLoginBusy(overlay, false, 'Connect Weixin');
|
||||
setConfigureInlineError(overlay, err.message || 'Interactive login failed');
|
||||
setConfigureInlineStatus(overlay, '');
|
||||
});
|
||||
}
|
||||
|
||||
function renderTelegramVerificationChallenge(overlay, verification) {
|
||||
|
||||
@@ -3238,6 +3238,26 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.configure-qr-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 16px 0 0 0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.configure-qr-image {
|
||||
width: min(260px, 100%);
|
||||
align-self: center;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.configure-verification-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -536,6 +536,8 @@ pub struct ExtensionSetupResponse {
|
||||
pub kind: String,
|
||||
pub secrets: Vec<SecretFieldInfo>,
|
||||
pub fields: Vec<SetupFieldInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub interactive_login: Option<crate::extensions::InteractiveLoginInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -568,6 +570,32 @@ pub struct ExtensionSetupRequest {
|
||||
pub fields: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtensionInteractiveLoginStartRequest {
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtensionInteractiveLoginPollRequest {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExtensionInteractiveLoginResponse {
|
||||
pub success: bool,
|
||||
pub status: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub qr_code_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub activated: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActionResponse {
|
||||
pub success: bool,
|
||||
|
||||
+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