From 7dfc5ddd0d5dfae8f53a0c8cfa79a82a2925a79f Mon Sep 17 00:00:00 2001 From: Coffee Date: Thu, 26 Mar 2026 12:09:07 +0800 Subject: [PATCH] Add WeChat typing indicators to the DM channel --- FEATURE_PARITY.md | 2 +- channels-src/wechat/src/api.rs | 79 ++++- channels-src/wechat/src/auth.rs | 1 + channels-src/wechat/src/lib.rs | 286 +++++++++++++++++- channels-src/wechat/src/state.rs | 25 +- channels-src/wechat/src/types.rs | 36 +++ .../2026-03-25-wechat-integration-design.md | 3 +- src/channels/web/server.rs | 37 ++- src/channels/web/static/app.js | 31 +- src/channels/web/static/i18n/en.js | 2 + src/channels/web/static/i18n/zh-CN.js | 2 + src/channels/web/static/style.css | 41 ++- src/channels/web/types.rs | 7 +- src/extensions/manager.rs | 2 +- src/extensions/wechat_login.rs | 2 +- 15 files changed, 505 insertions(+), 51 deletions(-) diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 9c47f68b..0e63b4c0 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -77,7 +77,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required | | Feishu/Lark | ✅ | 🚧 | P3 | WASM channel with Event Subscription v2.0; Bitable/Docx tools planned | | LINE | ✅ | ❌ | P3 | | -| WeChat (iLink bot) | ✅ | ❌ | P2 | Extension-first channel (`channels-src/wechat`), Phase 1 targets single-account parity on the upstream DM flow before multi-account follow-up | +| WeChat (iLink bot) | ✅ | ❌ | P2 | Extension-first channel (`channels-src/wechat`), single-account DM flow with QR login and typing; multi-account/media follow-up | | WebChat | ✅ | ✅ | - | Web gateway chat | | Matrix | ✅ | ❌ | P3 | E2EE support | | Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker | diff --git a/channels-src/wechat/src/api.rs b/channels-src/wechat/src/api.rs index 14ed9784..19bc6fd1 100644 --- a/channels-src/wechat/src/api.rs +++ b/channels-src/wechat/src/api.rs @@ -2,9 +2,9 @@ use base64::Engine as _; use crate::near::agent::channel_host; use crate::types::{ - BaseInfo, GetUpdatesRequest, GetUpdatesResponse, MessageItem, OutboundWechatMessage, - SendMessageRequest, TextItem, WechatConfig, MESSAGE_ITEM_TEXT, MESSAGE_STATE_FINISH, - MESSAGE_TYPE_BOT, + BaseInfo, GetConfigRequest, GetConfigResponse, GetUpdatesRequest, GetUpdatesResponse, + MessageItem, OutboundWechatMessage, SendMessageRequest, SendTypingRequest, SendTypingResponse, + TextItem, WechatConfig, MESSAGE_ITEM_TEXT, MESSAGE_STATE_FINISH, MESSAGE_TYPE_BOT, }; fn base_info() -> BaseInfo { @@ -114,3 +114,76 @@ pub fn send_text_message( Ok(()) } + +pub fn get_config( + config: &WechatConfig, + ilink_user_id: &str, + context_token: Option<&str>, +) -> Result { + let body = serde_json::to_vec(&GetConfigRequest { + ilink_user_id: ilink_user_id.to_string(), + context_token: context_token.map(str::to_string), + base_info: base_info(), + }) + .map_err(|e| format!("Failed to encode getConfig request: {e}"))?; + let headers = request_headers(&body); + let url = format!( + "{}ilink/bot/getconfig", + ensure_trailing_slash(&config.base_url) + ); + + let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(10_000)) + .map_err(|e| format!("getConfig request failed: {e}"))?; + + if response.status != 200 { + let body = String::from_utf8_lossy(&response.body); + return Err(format!("getConfig returned {}: {}", response.status, body)); + } + + serde_json::from_slice(&response.body) + .map_err(|e| format!("Failed to parse getConfig response: {e}")) +} + +pub fn send_typing( + config: &WechatConfig, + ilink_user_id: &str, + typing_ticket: &str, + status: i32, +) -> Result<(), String> { + let body = serde_json::to_vec(&SendTypingRequest { + ilink_user_id: ilink_user_id.to_string(), + typing_ticket: typing_ticket.to_string(), + status, + base_info: base_info(), + }) + .map_err(|e| format!("Failed to encode sendTyping request: {e}"))?; + let headers = request_headers(&body); + let url = format!( + "{}ilink/bot/sendtyping", + ensure_trailing_slash(&config.base_url) + ); + + let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(10_000)) + .map_err(|e| format!("sendTyping request failed: {e}"))?; + + if response.status != 200 { + let body = String::from_utf8_lossy(&response.body); + return Err(format!("sendTyping returned {}: {}", response.status, body)); + } + + let parsed: SendTypingResponse = serde_json::from_slice(&response.body) + .map_err(|e| format!("Failed to parse sendTyping response: {e}"))?; + + if parsed.ret.unwrap_or(0) != 0 { + let errmsg = parsed + .errmsg + .as_deref() + .unwrap_or("unknown WeChat sendTyping error"); + return Err(format!( + "sendTyping returned ret={} errmsg={errmsg}", + parsed.ret.unwrap_or(-1) + )); + } + + Ok(()) +} diff --git a/channels-src/wechat/src/auth.rs b/channels-src/wechat/src/auth.rs index cfd516da..2535f4b3 100644 --- a/channels-src/wechat/src/auth.rs +++ b/channels-src/wechat/src/auth.rs @@ -2,4 +2,5 @@ pub const TOKEN_SECRET_NAME: &str = "wechat_bot_token"; pub const CONFIG_PATH: &str = "config.json"; pub const GET_UPDATES_BUF_PATH: &str = "state/get_updates_buf.json"; pub const CONTEXT_TOKENS_PATH: &str = "state/context_tokens.json"; +pub const TYPING_TICKETS_PATH: &str = "state/typing_tickets.json"; pub const SESSION_EXPIRED_PATH: &str = "state/session_expired"; diff --git a/channels-src/wechat/src/lib.rs b/channels-src/wechat/src/lib.rs index 2840ff32..780b8d23 100644 --- a/channels-src/wechat/src/lib.rs +++ b/channels-src/wechat/src/lib.rs @@ -9,7 +9,7 @@ mod state; mod types; use exports::near::agent::channel::{ - AgentResponse, ChannelConfig, Guest, PollConfig, StatusUpdate, + AgentResponse, ChannelConfig, Guest, PollConfig, StatusType, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage}; use serde_json::json; @@ -17,13 +17,22 @@ use serde_json::json; use crate::auth::TOKEN_SECRET_NAME; use crate::state::{ clear_session_expired, load_config, load_context_tokens, load_get_updates_buf, - mark_session_expired, persist_config, persist_context_tokens, persist_get_updates_buf, - session_expired, + load_typing_tickets, mark_session_expired, persist_config, persist_context_tokens, + persist_get_updates_buf, persist_typing_tickets, session_expired, TypingTicketEntry, }; use crate::types::{ OutboundMetadata, WechatConfig, WechatMessage, MESSAGE_ITEM_TEXT, MESSAGE_TYPE_USER, + TYPING_STATUS_CANCEL, TYPING_STATUS_TYPING, }; +const TYPING_TICKET_TTL_MS: u64 = 24 * 60 * 60 * 1000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WechatStatusAction { + Typing, + Cancel, +} + struct WechatChannel; impl Guest for WechatChannel { @@ -151,6 +160,18 @@ impl Guest for WechatChannel { .context_token .clone() .or_else(|| context_tokens.get(&metadata.from_user_id).cloned()); + if let Err(error) = send_typing_indicator( + &config, + &metadata, + context_token.as_deref(), + TYPING_STATUS_CANCEL, + false, + ) { + channel_host::log( + channel_host::LogLevel::Debug, + &format!("Failed to cancel WeChat typing indicator before reply: {error}"), + ); + } api::send_text_message( &config, @@ -160,7 +181,42 @@ impl Guest for WechatChannel { ) } - fn on_status(_update: StatusUpdate) {} + fn on_status(update: StatusUpdate) { + let Some(action) = classify_status_update(&update) else { + return; + }; + let metadata = match serde_json::from_str::(&update.metadata_json) { + Ok(metadata) => metadata, + Err(_) => { + channel_host::log( + channel_host::LogLevel::Debug, + "on_status: no valid WeChat metadata, skipping typing update", + ); + return; + } + }; + let config = load_config(); + let context_tokens = load_context_tokens(); + let context_token = resolve_context_token(&metadata, &context_tokens); + + let (typing_status, allow_ticket_fetch) = match action { + WechatStatusAction::Typing => (TYPING_STATUS_TYPING, true), + WechatStatusAction::Cancel => (TYPING_STATUS_CANCEL, false), + }; + + if let Err(error) = send_typing_indicator( + &config, + &metadata, + context_token.as_deref(), + typing_status, + allow_ticket_fetch, + ) { + channel_host::log( + channel_host::LogLevel::Debug, + &format!("WeChat typing update failed: {error}"), + ); + } + } fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> { Ok(()) @@ -215,4 +271,226 @@ fn extract_text(message: &WechatMessage) -> String { .unwrap_or_default() } +fn is_terminal_text_status(message: &str) -> bool { + let trimmed = message.trim(); + trimmed.eq_ignore_ascii_case("done") + || trimmed.eq_ignore_ascii_case("interrupted") + || trimmed.eq_ignore_ascii_case("awaiting approval") + || trimmed.eq_ignore_ascii_case("rejected") +} + +fn classify_status_update(update: &StatusUpdate) -> Option { + match update.status { + StatusType::Thinking => Some(WechatStatusAction::Typing), + StatusType::Done + | StatusType::Interrupted + | StatusType::ApprovalNeeded + | StatusType::AuthRequired => Some(WechatStatusAction::Cancel), + StatusType::Status if is_terminal_text_status(&update.message) => { + Some(WechatStatusAction::Cancel) + } + StatusType::ToolStarted + | StatusType::ToolCompleted + | StatusType::ToolResult + | StatusType::Status + | StatusType::JobStarted + | StatusType::AuthCompleted => None, + } +} + +fn resolve_context_token( + metadata: &OutboundMetadata, + context_tokens: &std::collections::HashMap, +) -> Option { + metadata + .context_token + .clone() + .or_else(|| context_tokens.get(&metadata.from_user_id).cloned()) +} + +fn cached_typing_ticket(user_id: &str) -> Option { + let tickets = load_typing_tickets(); + let ticket = tickets.get(user_id)?; + let trimmed = ticket.ticket.trim(); + if trimmed.is_empty() { + return None; + } + + let age_ms = channel_host::now_millis().saturating_sub(ticket.fetched_at_ms); + if age_ms >= TYPING_TICKET_TTL_MS { + return None; + } + + Some(trimmed.to_string()) +} + +fn persist_typing_ticket(user_id: &str, ticket: &str) -> Result<(), String> { + let mut tickets = load_typing_tickets(); + tickets.insert( + user_id.to_string(), + TypingTicketEntry { + ticket: ticket.to_string(), + fetched_at_ms: channel_host::now_millis(), + }, + ); + persist_typing_tickets(&tickets) +} + +fn clear_typing_ticket(user_id: &str) -> Result<(), String> { + let mut tickets = load_typing_tickets(); + if tickets.remove(user_id).is_some() { + persist_typing_tickets(&tickets)?; + } + Ok(()) +} + +fn resolve_typing_ticket( + config: &WechatConfig, + user_id: &str, + context_token: Option<&str>, +) -> Result, String> { + if let Some(ticket) = cached_typing_ticket(user_id) { + return Ok(Some(ticket)); + } + + let response = api::get_config(config, user_id, context_token)?; + if response.ret.unwrap_or(0) != 0 { + let errmsg = response + .errmsg + .as_deref() + .unwrap_or("unknown WeChat getConfig error"); + return Err(format!( + "WeChat getConfig returned ret={} errmsg={errmsg}", + response.ret.unwrap_or(-1) + )); + } + + let Some(ticket) = response + .typing_ticket + .as_deref() + .map(str::trim) + .filter(|ticket| !ticket.is_empty()) + else { + return Ok(None); + }; + + if let Err(error) = persist_typing_ticket(user_id, ticket) { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("Failed to persist WeChat typing ticket: {error}"), + ); + } + + Ok(Some(ticket.to_string())) +} + +fn send_typing_indicator( + config: &WechatConfig, + metadata: &OutboundMetadata, + context_token: Option<&str>, + status: i32, + allow_ticket_fetch: bool, +) -> Result<(), String> { + let ticket = if allow_ticket_fetch { + resolve_typing_ticket(config, &metadata.from_user_id, context_token)? + } else { + cached_typing_ticket(&metadata.from_user_id) + }; + + let Some(ticket) = ticket else { + return Ok(()); + }; + + if let Err(error) = api::send_typing(config, &metadata.from_user_id, &ticket, status) { + let _ = clear_typing_ticket(&metadata.from_user_id); + return Err(error); + } + + Ok(()) +} + export!(WechatChannel); + +#[cfg(test)] +mod tests { + use super::{classify_status_update, WechatStatusAction}; + use crate::exports::near::agent::channel::{StatusType, StatusUpdate}; + + #[test] + fn test_classify_status_update_thinking_starts_typing() { + let update = StatusUpdate { + status: StatusType::Thinking, + message: "Thinking...".to_string(), + metadata_json: "{}".to_string(), + }; + + assert_eq!( + classify_status_update(&update), + Some(WechatStatusAction::Typing) + ); + } + + #[test] + fn test_classify_status_update_done_cancels_typing() { + let update = StatusUpdate { + status: StatusType::Done, + message: "Done".to_string(), + metadata_json: "{}".to_string(), + }; + + assert_eq!( + classify_status_update(&update), + Some(WechatStatusAction::Cancel) + ); + } + + #[test] + fn test_classify_status_update_approval_needed_cancels_typing() { + let update = StatusUpdate { + status: StatusType::ApprovalNeeded, + message: "Approval needed".to_string(), + metadata_json: "{}".to_string(), + }; + + assert_eq!( + classify_status_update(&update), + Some(WechatStatusAction::Cancel) + ); + } + + #[test] + fn test_classify_status_update_tool_started_is_ignored() { + let update = StatusUpdate { + status: StatusType::ToolStarted, + message: "Tool started".to_string(), + metadata_json: "{}".to_string(), + }; + + assert_eq!(classify_status_update(&update), None); + } + + #[test] + fn test_classify_status_update_terminal_text_status_cancels_typing() { + let update = StatusUpdate { + status: StatusType::Status, + message: "Awaiting approval".to_string(), + metadata_json: "{}".to_string(), + }; + + assert_eq!( + classify_status_update(&update), + Some(WechatStatusAction::Cancel) + ); + } + + #[test] + fn test_classify_status_update_progress_status_is_ignored() { + let update = StatusUpdate { + status: StatusType::Status, + message: "Context compaction started".to_string(), + metadata_json: "{}".to_string(), + }; + + assert_eq!(classify_status_update(&update), None); + } +} diff --git a/channels-src/wechat/src/state.rs b/channels-src/wechat/src/state.rs index 424cb317..69f487ba 100644 --- a/channels-src/wechat/src/state.rs +++ b/channels-src/wechat/src/state.rs @@ -1,9 +1,20 @@ use std::collections::HashMap; -use crate::auth::{CONFIG_PATH, CONTEXT_TOKENS_PATH, GET_UPDATES_BUF_PATH, SESSION_EXPIRED_PATH}; +use serde::{Deserialize, Serialize}; + +use crate::auth::{ + CONFIG_PATH, CONTEXT_TOKENS_PATH, GET_UPDATES_BUF_PATH, SESSION_EXPIRED_PATH, + TYPING_TICKETS_PATH, +}; use crate::near::agent::channel_host; use crate::types::WechatConfig; +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TypingTicketEntry { + pub ticket: String, + pub fetched_at_ms: u64, +} + pub fn load_config() -> WechatConfig { channel_host::workspace_read(CONFIG_PATH) .and_then(|raw| serde_json::from_str::(&raw).ok()) @@ -40,6 +51,18 @@ pub fn persist_context_tokens(tokens: &HashMap) -> Result<(), St channel_host::workspace_write(CONTEXT_TOKENS_PATH, &serialized).map_err(|e| e.to_string()) } +pub fn load_typing_tickets() -> HashMap { + channel_host::workspace_read(TYPING_TICKETS_PATH) + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + .unwrap_or_default() +} + +pub fn persist_typing_tickets(tickets: &HashMap) -> Result<(), String> { + let serialized = + serde_json::to_string(tickets).map_err(|e| format!("Failed to serialize tickets: {e}"))?; + channel_host::workspace_write(TYPING_TICKETS_PATH, &serialized).map_err(|e| e.to_string()) +} + pub fn session_expired() -> bool { matches!( channel_host::workspace_read(SESSION_EXPIRED_PATH).as_deref(), diff --git a/channels-src/wechat/src/types.rs b/channels-src/wechat/src/types.rs index 48735af7..03b8ad9f 100644 --- a/channels-src/wechat/src/types.rs +++ b/channels-src/wechat/src/types.rs @@ -50,6 +50,14 @@ pub struct GetUpdatesRequest { pub base_info: BaseInfo, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetConfigRequest { + pub ilink_user_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_token: Option, + pub base_info: BaseInfo, +} + #[derive(Debug, Clone, Deserialize)] pub struct GetUpdatesResponse { #[serde(default)] @@ -70,6 +78,14 @@ pub struct SendMessageRequest { pub base_info: BaseInfo, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SendTypingRequest { + pub ilink_user_id: String, + pub typing_ticket: String, + pub status: i32, + pub base_info: BaseInfo, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct OutboundWechatMessage { pub from_user_id: String, @@ -100,6 +116,24 @@ pub struct WechatMessage { pub item_list: Vec, } +#[derive(Debug, Clone, Deserialize)] +pub struct GetConfigResponse { + #[serde(default)] + pub ret: Option, + #[serde(default)] + pub errmsg: Option, + #[serde(default)] + pub typing_ticket: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SendTypingResponse { + #[serde(default)] + pub ret: Option, + #[serde(default)] + pub errmsg: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct MessageItem { #[serde(default)] @@ -130,3 +164,5 @@ pub const MESSAGE_TYPE_USER: i32 = 1; pub const MESSAGE_TYPE_BOT: i32 = 2; pub const MESSAGE_STATE_FINISH: i32 = 2; pub const MESSAGE_ITEM_TEXT: i32 = 1; +pub const TYPING_STATUS_TYPING: i32 = 1; +pub const TYPING_STATUS_CANCEL: i32 = 2; diff --git a/docs/plans/2026-03-25-wechat-integration-design.md b/docs/plans/2026-03-25-wechat-integration-design.md index a6573cfd..43eb82cb 100644 --- a/docs/plans/2026-03-25-wechat-integration-design.md +++ b/docs/plans/2026-03-25-wechat-integration-design.md @@ -76,6 +76,7 @@ The point of this phase is to keep the channel aligned with upstream behavior wh - direct-message text receive/send - `getupdates` long-poll loop - `sendmessage` outbound replies +- typing indicators via `getconfig` and `sendtyping` - `context_token` persistence - `get_updates_buf` persistence - login persistence across restart @@ -89,7 +90,6 @@ The point of this phase is to keep the channel aligned with upstream behavior wh These are upstream features, so they belong on the roadmap, but they do not need to block the first implementation cut: -- typing indicators via `getconfig` and `sendtyping` - media upload/send via `getuploadurl` We should not spend time listing non-goals that come from outside the upstream capability boundary. @@ -257,5 +257,4 @@ Use a mock iLink server to cover: After Phase 1 is stable, add the upstream features we intentionally deferred: - multi-account support -- typing indicators - media upload/send diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index fb8ab15d..c3d78987 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -596,7 +596,7 @@ pub async fn start_server( style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \ font-src https://fonts.gstatic.com; \ connect-src 'self'; \ - img-src 'self' data:; \ + img-src 'self' data: https://liteapp.weixin.qq.com; \ object-src 'none'; \ frame-ancestors 'none'; \ base-uri 'self'; \ @@ -3010,6 +3010,35 @@ mod tests { Ok(()) } + #[test] + fn test_wechat_active_channel_does_not_require_pairing_status() -> Result<(), String> { + let ext = InstalledExtension { + name: "wechat".to_string(), + kind: ExtensionKind::WasmChannel, + display_name: Some("WeChat".to_string()), + description: None, + url: None, + authenticated: true, + active: true, + tools: Vec::new(), + needs_setup: true, + has_auth: false, + installed: true, + activation_error: None, + version: None, + }; + + let status = classify_wasm_channel_activation(&ext, false, false); + if status != Some(ExtensionActivationStatus::Active) { + return Err(format!( + "wechat should be active after QR login, got {:?}", + status + )); + } + + Ok(()) + } + #[test] fn test_channel_relay_activation_status_is_preserved() -> Result<(), String> { let relay = InstalledExtension { @@ -3242,7 +3271,7 @@ mod tests { crate::extensions::InteractiveLoginStartResult { session_id: "wechat-session-42".to_string(), status: "pending".to_string(), - message: "Scan the QR code in WeChat to finish connecting.".to_string(), + message: "Open the WeChat QR page to continue.".to_string(), qr_code_url: Some("https://qr.example/42".to_string()), instructions: Some( "Keep this window open while you scan and confirm on your phone." @@ -3605,6 +3634,10 @@ mod tests { csp_str.contains("object-src 'none'"), "CSP must contain object-src 'none'" ); + assert!( + csp_str.contains("img-src 'self' data: https://liteapp.weixin.qq.com"), + "CSP must allow WeChat QR images from liteapp.weixin.qq.com" + ); assert!( csp_str.contains("frame-ancestors 'none'"), "CSP must contain frame-ancestors 'none'" diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index c362b293..4e3ca755 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -3251,25 +3251,18 @@ function renderInteractiveLoginPanel() { const title = document.createElement('div'); title.className = 'configure-verification-title'; - title.textContent = 'WeChat QR Login'; + title.textContent = 'Open WeChat QR Page'; panel.appendChild(title); const status = document.createElement('div'); status.className = 'configure-verification-instructions'; - status.textContent = 'Generate a QR code to connect this channel.'; + status.textContent = 'The QR flow opens in a separate tab.'; status.dataset.qrStatus = 'true'; panel.appendChild(status); - const img = document.createElement('img'); - img.className = 'configure-qr-image'; - img.alt = 'WeChat 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.textContent = 'Open QR Page'; link.target = '_blank'; link.rel = 'noreferrer noopener'; link.style.display = 'none'; @@ -3291,17 +3284,13 @@ 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 = ''; + status.textContent = res.status === 'refreshed' + ? 'The QR page was refreshed. Open it again if needed.' + : 'The QR flow opens in a separate tab.'; } if (link && res.qr_code_url) { @@ -3322,7 +3311,7 @@ function setInteractiveLoginBusy(overlay, busy, label) { function startInteractiveLogin(name, overlay) { if (!overlay || !document.body.contains(overlay)) return; clearConfigureInlineError(overlay); - setConfigureInlineStatus(overlay, 'Generating WeChat QR code...'); + setConfigureInlineStatus(overlay, 'Preparing WeChat QR page...'); setInteractiveLoginBusy(overlay, true, 'Waiting for scan...'); apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/start', { @@ -3703,9 +3692,9 @@ function renderWasmChannelStepper(ext) { var status = ext.activation_status || 'installed'; var steps = [ - { label: 'Installed', key: 'installed' }, - { label: 'Configured', key: 'configured' }, - { label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' }, + { label: I18n.t('status.installed'), key: 'installed' }, + { label: I18n.t('status.configured'), key: 'configured' }, + { label: status === 'pairing' ? I18n.t('status.pairingShort') : I18n.t('status.active'), key: 'active' }, ]; var reachedIdx; diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index 761767fe..d2f5911e 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -54,7 +54,9 @@ I18n.register('en', { 'status.restart': 'Restart', 'status.active': 'Active', 'status.installed': 'Installed', + 'status.configured': 'Configured', 'status.awaitingPairing': 'Awaiting Pairing', + 'status.pairingShort': 'Pairing', // Dashboard 'dashboard.connections': 'Connections', diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index 0fb1568a..b40e148d 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -54,7 +54,9 @@ I18n.register('zh-CN', { 'status.restart': '重启', 'status.active': '已激活', 'status.installed': '已安装', + 'status.configured': '已配置', 'status.awaitingPairing': '等待配对', + 'status.pairingShort': '配对中', // 仪表盘 'dashboard.connections': '连接数', diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 957412c0..b804e7d3 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -2961,15 +2961,18 @@ body { /* WASM channel setup stepper */ .ext-stepper { display: flex; - align-items: center; + align-items: flex-start; gap: 0; margin: 8px 0 4px; + min-width: 0; } .stepper-step { display: flex; align-items: center; - gap: 4px; + gap: 6px; + min-width: 0; + flex: 1 1 0; } .stepper-circle { @@ -2986,7 +2989,10 @@ body { .stepper-label { font-size: var(--text-xs); - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.25; + min-width: 0; } .stepper-step.completed .stepper-circle { @@ -3043,7 +3049,8 @@ body { height: 2px; background: var(--border); margin: 0 4px; - flex-shrink: 0; + flex: 0 0 20px; + align-self: center; } .stepper-connector.completed { @@ -3249,15 +3256,6 @@ body { 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; @@ -3282,14 +3280,29 @@ body { } .configure-verification-link { + display: inline-flex; + align-items: center; + justify-content: center; width: fit-content; + padding: 10px 14px; + border-radius: 10px; + border: 1px solid var(--accent); + background: var(--accent-subtle); color: var(--accent, var(--text-link, #4ea3ff)); font-size: var(--text-sm); + font-weight: 600; text-decoration: none; + transition: background var(--transition-fast), transform 150ms var(--ease-spring); } .configure-verification-link:hover { - text-decoration: underline; + background: var(--badge-sandbox-bg); + transform: translateY(-1px); + text-decoration: none; +} + +.configure-verification-link:active { + transform: scale(0.98); } .configure-inline-error { diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index ec7418f0..a1e89cc2 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -466,7 +466,12 @@ pub fn classify_wasm_channel_activation( } else if !ext.authenticated { ExtensionActivationStatus::Installed } else if ext.active { - if has_paired || has_owner_binding { + // WeChat QR login already proves channel ownership and does not have a + // separate owner-binding/pairing phase like Telegram DM verification. + if ext.name == crate::extensions::wechat_login::WECHAT_CHANNEL_NAME + || has_paired + || has_owner_binding + { ExtensionActivationStatus::Active } else { ExtensionActivationStatus::Pairing diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 85d79c5b..785f0786 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -7062,7 +7062,7 @@ mod tests { InteractiveLoginStartResult { session_id: "wechat-session-1".to_string(), status: "pending".to_string(), - message: "Scan the QR code in WeChat to finish connecting.".to_string(), + message: "Open the WeChat QR page to continue.".to_string(), qr_code_url: Some("https://qr.example/one".to_string()), instructions: Some( "Keep this window open while you scan and confirm on your phone." diff --git a/src/extensions/wechat_login.rs b/src/extensions/wechat_login.rs index 393f0288..66f7317b 100644 --- a/src/extensions/wechat_login.rs +++ b/src/extensions/wechat_login.rs @@ -135,7 +135,7 @@ fn build_pending_login( let result = InteractiveLoginStartResult { session_id, status: "pending".to_string(), - message: "Scan the QR code in WeChat to finish connecting.".to_string(), + message: "Open the WeChat QR page to continue.".to_string(), qr_code_url: Some(qr.qrcode_img_content), instructions: Some( "Keep this window open while you scan and confirm on your phone.".to_string(),