Add WeChat typing indicators to the DM channel

This commit is contained in:
Coffee
2026-03-26 12:09:07 +08:00
parent 60aaecd684
commit 7dfc5ddd0d
15 changed files with 505 additions and 51 deletions
+1 -1
View File
@@ -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 |
+76 -3
View File
@@ -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<GetConfigResponse, String> {
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(())
}
+1
View File
@@ -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";
+282 -4
View File
@@ -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::<OutboundMetadata>(&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<WechatStatusAction> {
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<String, String>,
) -> Option<String> {
metadata
.context_token
.clone()
.or_else(|| context_tokens.get(&metadata.from_user_id).cloned())
}
fn cached_typing_ticket(user_id: &str) -> Option<String> {
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<Option<String>, 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);
}
}
+24 -1
View File
@@ -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::<WechatConfig>(&raw).ok())
@@ -40,6 +51,18 @@ pub fn persist_context_tokens(tokens: &HashMap<String, String>) -> Result<(), St
channel_host::workspace_write(CONTEXT_TOKENS_PATH, &serialized).map_err(|e| e.to_string())
}
pub fn load_typing_tickets() -> HashMap<String, TypingTicketEntry> {
channel_host::workspace_read(TYPING_TICKETS_PATH)
.and_then(|raw| serde_json::from_str::<HashMap<String, TypingTicketEntry>>(&raw).ok())
.unwrap_or_default()
}
pub fn persist_typing_tickets(tickets: &HashMap<String, TypingTicketEntry>) -> 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(),
+36
View File
@@ -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<String>,
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<MessageItem>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetConfigResponse {
#[serde(default)]
pub ret: Option<i32>,
#[serde(default)]
pub errmsg: Option<String>,
#[serde(default)]
pub typing_ticket: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SendTypingResponse {
#[serde(default)]
pub ret: Option<i32>,
#[serde(default)]
pub errmsg: Option<String>,
}
#[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;
@@ -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
+35 -2
View File
@@ -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'"
+10 -21
View File
@@ -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;
+2
View File
@@ -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',
+2
View File
@@ -54,7 +54,9 @@ I18n.register('zh-CN', {
'status.restart': '重启',
'status.active': '已激活',
'status.installed': '已安装',
'status.configured': '已配置',
'status.awaitingPairing': '等待配对',
'status.pairingShort': '配对中',
// 仪表盘
'dashboard.connections': '连接数',
+27 -14
View File
@@ -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 {
+6 -1
View File
@@ -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
+1 -1
View File
@@ -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."
+1 -1
View File
@@ -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(),