mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-29 00:49:31 +00:00
Add WeChat typing indicators to the DM channel
This commit is contained in:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -54,7 +54,9 @@ I18n.register('zh-CN', {
|
||||
'status.restart': '重启',
|
||||
'status.active': '已激活',
|
||||
'status.installed': '已安装',
|
||||
'status.configured': '已配置',
|
||||
'status.awaitingPairing': '等待配对',
|
||||
'status.pairingShort': '配对中',
|
||||
|
||||
// 仪表盘
|
||||
'dashboard.connections': '连接数',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user