Add Weixin channel with QR login and web setup flow

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