diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index ad2db551..74395f27 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -77,6 +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/weixin`), Phase 1 targets single-account parity on the upstream DM flow before multi-account follow-up | | WebChat | ✅ | ✅ | - | Web gateway chat | | Matrix | ✅ | ❌ | P3 | E2EE support | | Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker | diff --git a/channels-src/weixin/.gitignore b/channels-src/weixin/.gitignore new file mode 100644 index 00000000..fa74b8eb --- /dev/null +++ b/channels-src/weixin/.gitignore @@ -0,0 +1,2 @@ +/target +/*.wasm diff --git a/channels-src/weixin/Cargo.lock b/channels-src/weixin/Cargo.lock new file mode 100644 index 00000000..903d0423 --- /dev/null +++ b/channels-src/weixin/Cargo.lock @@ -0,0 +1,408 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spdx" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" +dependencies = [ + "smallvec", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1" +dependencies = [ + "leb128", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7" +dependencies = [ + "anyhow", + "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25" +dependencies = [ + "ahash", + "bitflags", + "hashbrown 0.14.5", + "indexmap", + "semver", +] + +[[package]] +name = "weixin-channel" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3" +dependencies = [ + "wit-bindgen-rt", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.220.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/channels-src/weixin/Cargo.toml b/channels-src/weixin/Cargo.toml new file mode 100644 index 00000000..ed143861 --- /dev/null +++ b/channels-src/weixin/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "weixin-channel" +version = "0.1.0" +edition = "2021" +description = "Weixin iLink Bot channel for IronClaw" +license = "MIT OR Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "0.36" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +base64 = "0.22" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 + +[workspace] diff --git a/channels-src/weixin/build.sh b/channels-src/weixin/build.sh new file mode 100755 index 00000000..f4a72185 --- /dev/null +++ b/channels-src/weixin/build.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +echo "Building Weixin channel WASM component..." + +cargo build --release --target wasm32-wasip2 + +WASM_PATH="target/wasm32-wasip2/release/weixin_channel.wasm" + +if [ -f "$WASM_PATH" ]; then + wasm-tools component new "$WASM_PATH" -o weixin.wasm 2>/dev/null || cp "$WASM_PATH" weixin.wasm + wasm-tools strip weixin.wasm -o weixin.wasm + + echo "Built: weixin.wasm ($(du -h weixin.wasm | cut -f1))" + echo "" + echo "To install:" + echo " mkdir -p ~/.ironclaw/channels" + echo " cp weixin.wasm weixin.capabilities.json ~/.ironclaw/channels/" +else + echo "Error: WASM output not found at $WASM_PATH" + exit 1 +fi diff --git a/channels-src/weixin/src/api.rs b/channels-src/weixin/src/api.rs new file mode 100644 index 00000000..1f694fe5 --- /dev/null +++ b/channels-src/weixin/src/api.rs @@ -0,0 +1,116 @@ +use base64::Engine as _; + +use crate::near::agent::channel_host; +use crate::types::{ + BaseInfo, GetUpdatesRequest, GetUpdatesResponse, MessageItem, OutboundWeixinMessage, + SendMessageRequest, TextItem, WeixinConfig, MESSAGE_ITEM_TEXT, MESSAGE_STATE_FINISH, + MESSAGE_TYPE_BOT, +}; + +fn base_info() -> BaseInfo { + BaseInfo { + channel_version: env!("CARGO_PKG_VERSION").to_string(), + } +} + +fn ensure_trailing_slash(base_url: &str) -> String { + if base_url.ends_with('/') { + base_url.to_string() + } else { + format!("{base_url}/") + } +} + +fn random_wechat_uin() -> String { + let seed = (channel_host::now_millis() % u32::MAX as u64) as u32; + base64::engine::general_purpose::STANDARD.encode(seed.to_string()) +} + +fn request_headers(body: &[u8]) -> String { + serde_json::json!({ + "Content-Type": "application/json", + "AuthorizationType": "ilink_bot_token", + "Authorization": "Bearer {WEIXIN_BOT_TOKEN}", + "Content-Length": body.len().to_string(), + "X-WECHAT-UIN": random_wechat_uin(), + }) + .to_string() +} + +pub fn get_updates( + config: &WeixinConfig, + get_updates_buf: &str, +) -> Result { + let body = serde_json::to_vec(&GetUpdatesRequest { + get_updates_buf: get_updates_buf.to_string(), + base_info: base_info(), + }) + .map_err(|e| format!("Failed to encode getUpdates request: {e}"))?; + let headers = request_headers(&body); + let url = format!( + "{}ilink/bot/getupdates", + ensure_trailing_slash(&config.base_url) + ); + let response = channel_host::http_request( + "POST", + &url, + &headers, + Some(&body), + Some(config.long_poll_timeout_ms), + ) + .map_err(|e| format!("getUpdates request failed: {e}"))?; + + if response.status != 200 { + let body = String::from_utf8_lossy(&response.body); + return Err(format!("getUpdates returned {}: {}", response.status, body)); + } + + serde_json::from_slice(&response.body) + .map_err(|e| format!("Failed to parse getUpdates response: {e}")) +} + +pub fn send_text_message( + config: &WeixinConfig, + to_user_id: &str, + text: &str, + context_token: Option<&str>, +) -> Result<(), String> { + let message = SendMessageRequest { + msg: OutboundWeixinMessage { + from_user_id: String::new(), + to_user_id: to_user_id.to_string(), + client_id: format!("weixin-{}", channel_host::now_millis()), + message_type: MESSAGE_TYPE_BOT, + message_state: MESSAGE_STATE_FINISH, + item_list: vec![MessageItem { + r#type: Some(MESSAGE_ITEM_TEXT), + text_item: Some(TextItem { + text: text.to_string(), + }), + }], + context_token: context_token.map(str::to_string), + }, + base_info: base_info(), + }; + + let body = serde_json::to_vec(&message) + .map_err(|e| format!("Failed to encode sendMessage request: {e}"))?; + let headers = request_headers(&body); + let url = format!( + "{}ilink/bot/sendmessage", + ensure_trailing_slash(&config.base_url) + ); + + let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(15_000)) + .map_err(|e| format!("sendMessage request failed: {e}"))?; + + if response.status != 200 { + let body = String::from_utf8_lossy(&response.body); + return Err(format!( + "sendMessage returned {}: {}", + response.status, body + )); + } + + Ok(()) +} diff --git a/channels-src/weixin/src/auth.rs b/channels-src/weixin/src/auth.rs new file mode 100644 index 00000000..66e593de --- /dev/null +++ b/channels-src/weixin/src/auth.rs @@ -0,0 +1,5 @@ +pub const TOKEN_SECRET_NAME: &str = "weixin_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 SESSION_EXPIRED_PATH: &str = "state/session_expired"; diff --git a/channels-src/weixin/src/lib.rs b/channels-src/weixin/src/lib.rs new file mode 100644 index 00000000..26c7ea16 --- /dev/null +++ b/channels-src/weixin/src/lib.rs @@ -0,0 +1,218 @@ +wit_bindgen::generate!({ + world: "sandboxed-channel", + path: "../../wit/channel.wit", +}); + +mod api; +mod auth; +mod state; +mod types; + +use exports::near::agent::channel::{ + AgentResponse, ChannelConfig, Guest, PollConfig, StatusUpdate, +}; +use near::agent::channel_host::{self, EmittedMessage}; +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, +}; +use crate::types::{ + OutboundMetadata, WeixinConfig, WeixinMessage, MESSAGE_ITEM_TEXT, MESSAGE_TYPE_USER, +}; + +struct WeixinChannel; + +impl Guest for WeixinChannel { + fn on_start(config_json: String) -> Result { + let config = serde_json::from_str::(&config_json) + .map_err(|e| format!("Failed to parse Weixin config: {e}"))?; + persist_config(&config)?; + clear_session_expired(); + + Ok(ChannelConfig { + display_name: "Weixin".to_string(), + http_endpoints: Vec::new(), + poll: Some(PollConfig { + interval_ms: config.poll_interval_ms.max(30_000), + enabled: true, + }), + }) + } + + fn on_http_request( + _req: exports::near::agent::channel::IncomingHttpRequest, + ) -> exports::near::agent::channel::OutgoingHttpResponse { + exports::near::agent::channel::OutgoingHttpResponse { + status: 404, + headers_json: "{}".to_string(), + body: b"{\"error\":\"weixin channel does not expose webhooks\"}".to_vec(), + } + } + + fn on_poll() { + if session_expired() { + channel_host::log( + channel_host::LogLevel::Warn, + "Weixin session is marked expired; reconnect the channel to resume polling", + ); + return; + } + + if !channel_host::secret_exists(TOKEN_SECRET_NAME) { + channel_host::log( + channel_host::LogLevel::Warn, + "Weixin bot token is missing; skipping poll", + ); + return; + } + + let config = load_config(); + let cursor = load_get_updates_buf(); + let mut context_tokens = load_context_tokens(); + + match api::get_updates(&config, &cursor) { + Ok(response) => { + if response.errcode == Some(-14) { + mark_session_expired(); + channel_host::log( + channel_host::LogLevel::Error, + "Weixin session expired; reconnect the channel", + ); + return; + } + + if response.ret.unwrap_or(0) != 0 { + let errmsg = response + .errmsg + .as_deref() + .unwrap_or("unknown Weixin polling error"); + channel_host::log( + channel_host::LogLevel::Warn, + &format!( + "Weixin getUpdates returned ret={} errmsg={errmsg}", + response.ret.unwrap_or(-1) + ), + ); + } + + if let Some(next_cursor) = response.get_updates_buf.as_deref() { + if next_cursor != cursor { + if let Err(error) = persist_get_updates_buf(next_cursor) { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("Failed to persist Weixin polling cursor: {error}"), + ); + } + } + } + + let mut context_tokens_changed = false; + for message in response.msgs { + if let Some(from_user_id) = message.from_user_id.as_deref() { + if let Some(context_token) = message.context_token.as_deref() { + let changed = context_tokens + .insert(from_user_id.to_string(), context_token.to_string()) + .as_deref() + != Some(context_token); + context_tokens_changed |= changed; + } + } + emit_incoming_message(message); + } + + if context_tokens_changed { + if let Err(error) = persist_context_tokens(&context_tokens) { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("Failed to persist Weixin context tokens: {error}"), + ); + } + } + } + Err(error) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Weixin polling failed: {error}"), + ); + } + } + } + + fn on_respond(response: AgentResponse) -> Result<(), String> { + let metadata = serde_json::from_str::(&response.metadata_json) + .map_err(|e| format!("Invalid Weixin response metadata: {e}"))?; + let config = load_config(); + let context_tokens = load_context_tokens(); + let context_token = metadata + .context_token + .clone() + .or_else(|| context_tokens.get(&metadata.from_user_id).cloned()); + + api::send_text_message( + &config, + &metadata.from_user_id, + response.content.trim(), + context_token.as_deref(), + ) + } + + fn on_status(_update: StatusUpdate) {} + + fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> { + Ok(()) + } + + fn on_shutdown() {} +} + +fn emit_incoming_message(message: WeixinMessage) { + if message.message_type != Some(MESSAGE_TYPE_USER) { + return; + } + + let Some(from_user_id) = message.from_user_id.as_deref() else { + return; + }; + + let text = extract_text(&message); + if text.trim().is_empty() { + return; + } + + let metadata = json!({ + "from_user_id": from_user_id, + "to_user_id": message.to_user_id, + "message_id": message.message_id, + "session_id": message.session_id, + "context_token": message.context_token, + }); + + channel_host::emit_message(&EmittedMessage { + user_id: from_user_id.to_string(), + user_name: None, + content: text, + thread_id: Some(format!("weixin:{from_user_id}")), + metadata_json: metadata.to_string(), + attachments: Vec::new(), + }); +} + +fn extract_text(message: &WeixinMessage) -> String { + message + .item_list + .iter() + .find_map(|item| { + if item.r#type == Some(MESSAGE_ITEM_TEXT) { + item.text_item.as_ref().map(|item| item.text.clone()) + } else { + None + } + }) + .unwrap_or_default() +} + +export!(WeixinChannel); diff --git a/channels-src/weixin/src/state.rs b/channels-src/weixin/src/state.rs new file mode 100644 index 00000000..fb63c81a --- /dev/null +++ b/channels-src/weixin/src/state.rs @@ -0,0 +1,56 @@ +use std::collections::HashMap; + +use crate::auth::{CONFIG_PATH, CONTEXT_TOKENS_PATH, GET_UPDATES_BUF_PATH, SESSION_EXPIRED_PATH}; +use crate::near::agent::channel_host; +use crate::types::WeixinConfig; + +pub fn load_config() -> WeixinConfig { + channel_host::workspace_read(CONFIG_PATH) + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or_default() +} + +pub fn persist_config(config: &WeixinConfig) -> Result<(), String> { + let serialized = + serde_json::to_string(config).map_err(|e| format!("Failed to serialize config: {e}"))?; + channel_host::workspace_write(CONFIG_PATH, &serialized).map_err(|e| e.to_string()) +} + +pub fn load_get_updates_buf() -> String { + channel_host::workspace_read(GET_UPDATES_BUF_PATH) + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or_default() +} + +pub fn persist_get_updates_buf(value: &str) -> Result<(), String> { + let serialized = + serde_json::to_string(value).map_err(|e| format!("Failed to serialize cursor: {e}"))?; + channel_host::workspace_write(GET_UPDATES_BUF_PATH, &serialized).map_err(|e| e.to_string()) +} + +pub fn load_context_tokens() -> HashMap { + channel_host::workspace_read(CONTEXT_TOKENS_PATH) + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + .unwrap_or_default() +} + +pub fn persist_context_tokens(tokens: &HashMap) -> Result<(), String> { + let serialized = + serde_json::to_string(tokens).map_err(|e| format!("Failed to serialize tokens: {e}"))?; + channel_host::workspace_write(CONTEXT_TOKENS_PATH, &serialized).map_err(|e| e.to_string()) +} + +pub fn session_expired() -> bool { + matches!( + channel_host::workspace_read(SESSION_EXPIRED_PATH).as_deref(), + Some("1") + ) +} + +pub fn clear_session_expired() { + let _ = channel_host::workspace_write(SESSION_EXPIRED_PATH, "0"); +} + +pub fn mark_session_expired() { + let _ = channel_host::workspace_write(SESSION_EXPIRED_PATH, "1"); +} diff --git a/channels-src/weixin/src/types.rs b/channels-src/weixin/src/types.rs new file mode 100644 index 00000000..27ec7e8e --- /dev/null +++ b/channels-src/weixin/src/types.rs @@ -0,0 +1,132 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WeixinConfig { + #[serde(default = "default_base_url")] + pub base_url: String, + #[serde(default = "default_bot_type")] + pub bot_type: String, + #[serde(default = "default_poll_interval_ms")] + pub poll_interval_ms: u32, + #[serde(default = "default_long_poll_timeout_ms")] + pub long_poll_timeout_ms: u32, +} + +fn default_base_url() -> String { + "https://ilinkai.weixin.qq.com".to_string() +} + +fn default_bot_type() -> String { + "3".to_string() +} + +fn default_poll_interval_ms() -> u32 { + 30_000 +} + +fn default_long_poll_timeout_ms() -> u32 { + 35_000 +} + +impl Default for WeixinConfig { + fn default() -> Self { + Self { + base_url: default_base_url(), + bot_type: default_bot_type(), + poll_interval_ms: default_poll_interval_ms(), + long_poll_timeout_ms: default_long_poll_timeout_ms(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct BaseInfo { + pub channel_version: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GetUpdatesRequest { + pub get_updates_buf: String, + pub base_info: BaseInfo, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GetUpdatesResponse { + #[serde(default)] + pub ret: Option, + #[serde(default)] + pub errcode: Option, + #[serde(default)] + pub errmsg: Option, + #[serde(default)] + pub msgs: Vec, + #[serde(default)] + pub get_updates_buf: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SendMessageRequest { + pub msg: OutboundWeixinMessage, + pub base_info: BaseInfo, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OutboundWeixinMessage { + pub from_user_id: String, + pub to_user_id: String, + pub client_id: String, + pub message_type: i32, + pub message_state: i32, + pub item_list: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_token: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WeixinMessage { + #[serde(default)] + pub message_id: Option, + #[serde(default)] + pub from_user_id: Option, + #[serde(default)] + pub to_user_id: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub message_type: Option, + #[serde(default)] + pub context_token: Option, + #[serde(default)] + pub item_list: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MessageItem { + #[serde(default)] + pub r#type: Option, + #[serde(default)] + pub text_item: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TextItem { + pub text: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OutboundMetadata { + pub from_user_id: String, + #[serde(default)] + pub to_user_id: Option, + #[serde(default)] + pub message_id: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub context_token: Option, +} + +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; diff --git a/channels-src/weixin/weixin.capabilities.json b/channels-src/weixin/weixin.capabilities.json new file mode 100644 index 00000000..a54018ae --- /dev/null +++ b/channels-src/weixin/weixin.capabilities.json @@ -0,0 +1,48 @@ +{ + "version": "0.1.0", + "wit_version": "0.3.0", + "type": "channel", + "name": "weixin", + "description": "Weixin iLink Bot channel for direct-message chat via long polling", + "setup": { + "required_secrets": [ + { + "name": "weixin_bot_token", + "prompt": "Connect this channel from the Weixin setup flow. IronClaw stores the bot token after QR login succeeds.", + "optional": false + } + ], + "setup_url": "https://ilinkai.weixin.qq.com" + }, + "capabilities": { + "http": { + "allowlist": [ + { "host": "ilinkai.weixin.qq.com", "path_prefix": "/" } + ], + "rate_limit": { + "requests_per_minute": 60, + "requests_per_hour": 1200 + } + }, + "secrets": { + "allowed_names": ["weixin_*"] + }, + "channel": { + "allowed_paths": [], + "allow_polling": true, + "min_poll_interval_ms": 30000, + "workspace_prefix": "channels/weixin/", + "callback_timeout_secs": 45, + "emit_rate_limit": { + "messages_per_minute": 100, + "messages_per_hour": 5000 + } + } + }, + "config": { + "base_url": "https://ilinkai.weixin.qq.com", + "bot_type": "3", + "poll_interval_ms": 30000, + "long_poll_timeout_ms": 35000 + } +} diff --git a/channels-src/whatsapp/Cargo.lock b/channels-src/whatsapp/Cargo.lock index 0e55d1e5..adefa9aa 100644 --- a/channels-src/whatsapp/Cargo.lock +++ b/channels-src/whatsapp/Cargo.lock @@ -269,7 +269,7 @@ dependencies = [ [[package]] name = "whatsapp-channel" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde_json", diff --git a/docs/plans/2026-03-25-weixin-integration-design.md b/docs/plans/2026-03-25-weixin-integration-design.md new file mode 100644 index 00000000..ad3b09b0 --- /dev/null +++ b/docs/plans/2026-03-25-weixin-integration-design.md @@ -0,0 +1,267 @@ +# Weixin Integration Design + +**Date:** 2026-03-25 +**Status:** Ready for implementation +**Goal:** Add Weixin support to IronClaw using the same upstream iLink Bot protocol as `@tencent-weixin/openclaw-weixin`, while keeping the implementation aligned with IronClaw's extension-first channel architecture. + +--- + +## Upstream Baseline + +The current upstream npm package is `@tencent-weixin/openclaw-weixin` version `2.0.1`. + +From the package README and source, the upstream Weixin channel does all of the following: + +- logs in by QR code against `https://ilinkai.weixin.qq.com` +- receives inbound messages by long-polling `ilink/bot/getupdates` +- sends outbound messages through `ilink/bot/sendmessage` +- uses `ilink/bot/getconfig` and `ilink/bot/sendtyping` for typing indicators +- uses `ilink/bot/getuploadurl` for media uploads +- persists `get_updates_buf` for long-poll resume +- persists `context_token` so replies stay attached to the right Weixin session +- supports multiple logged-in Weixin bot accounts +- treats Weixin as a direct-message-only channel +- block-sends replies instead of token streaming + +This design treats that upstream behavior as the capability boundary. We should not add scope based on features the upstream plugin does not have. + +--- + +## Implementation Direction + +IronClaw should **not** try to load the upstream OpenClaw plugin directly. + +Instead, IronClaw should implement a **native channel extension** under `channels-src/weixin/` and only extend the host/runtime where that support is generic and reusable. + +### Why not host the npm plugin directly + +- The upstream package depends on `openclaw/plugin-sdk/*` APIs and runtime contracts that IronClaw does not have. +- It assumes OpenClaw-specific lifecycle concepts such as `gateway.startAccount`. +- Recreating an OpenClaw-compatible Node plugin host inside IronClaw would be more work and more fragile than implementing the protocol directly. + +### Why `channels-src/weixin/` + +- It matches the existing layering used by other platform channels. +- It keeps platform protocol logic out of host-owned core modules. +- It leaves room for the channel to move outside this repo later without changing the host model. + +Recommended layout: + +```text +channels-src/ + weixin/ + Cargo.toml + build.sh + weixin.capabilities.json + src/ + lib.rs + api.rs + auth.rs + state.rs + types.rs +``` + +--- + +## Phase 1 Scope + +Phase 1 is a **single-account** implementation of the upstream Weixin channel. + +The point of this phase is to keep the channel aligned with upstream behavior while removing the one biggest source of host/runtime complexity: multi-account lifecycle. + +### Must-have in Phase 1 + +- QR code login +- one connected Weixin bot account +- direct-message text receive/send +- `getupdates` long-poll loop +- `sendmessage` outbound replies +- `context_token` persistence +- `get_updates_buf` persistence +- login persistence across restart +- extension-first packaging under `channels-src/weixin/` + +### Explicit simplification from upstream + +- multi-account support is deferred + +### Follow-up after Phase 1 + +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. + +--- + +## Proposed Architecture + +```mermaid +flowchart LR + A["Core WASM channel host"] --> B["Weixin channel extension"] + C["Generic login UI/API"] --> D["QR login session"] + D --> E["Secret storage"] + E --> B + B --> F["getupdates long-poll"] + F --> G["IncomingMessage"] + G --> H["ChannelManager -> Agent"] + H --> I["sendmessage reply"] +``` + +### Extension responsibilities + +`channels-src/weixin/` should own: + +- iLink API request/response types +- QR login protocol calls +- long-polling `getupdates` +- `context_token` storage and lookup +- outbound `sendmessage` +- Weixin-specific status/error mapping + +### Host responsibilities + +IronClaw core should only own reusable pieces: + +- installing and activating the WASM channel +- generic secret persistence +- generic QR/device-login session handling for channels +- exposing login flow through authenticated UI/API +- starting and polling the channel runtime + +--- + +## Data And State Model + +Phase 1 is single-account, so state should stay simple. + +### Secrets + +- `weixin_bot_token` + +This is written after QR login succeeds and reused on restart. + +### Channel state + +Under the channel workspace prefix, persist: + +- `state/get_updates_buf.json` +- `state/context_tokens.json` + +`context_tokens.json` maps the Weixin peer to its latest `context_token`. + +### Inbound message mapping + +For each inbound Weixin DM: + +- `channel = "weixin"` +- `user_id = ` or owner scope if it is the bound owner +- `thread_id = Some("weixin:")` +- `conversation_scope_id = Some("weixin:")` + +`metadata_json` should include: + +- `from_user_id` +- `to_user_id` +- `message_id` +- `context_token` + +That is enough for `on_respond()` to send the reply back to the right peer. + +--- + +## Minimal Host Uplift + +The current extension host is close, but Phase 1 still needs one important addition: a generic interactive login flow for channels. + +Minimum host support needed: + +1. Start a channel login session. +2. Return QR payload plus a session identifier. +3. Poll login session status. +4. On success, write the returned token to channel secrets. +5. Reload or reactivate the channel so polling starts automatically. + +This should be added as a generic channel-auth capability, not as Weixin-specific core logic. + +--- + +## User Flow + +Phase 1 should be **web-first**, because the target user is a normal Weixin user rather than a CLI-only operator. + +1. Install or enable the `weixin` channel extension. +2. Click "Connect Weixin". +3. Web UI requests a login session from the host. +4. Web UI displays the QR code. +5. User scans and confirms on their phone. +6. Host stores `weixin_bot_token`. +7. Channel reloads and starts polling. +8. User sends a DM in Weixin and receives IronClaw replies there. + +CLI support can still exist for development, but it should not be the primary Phase 1 UX. + +--- + +## Message Handling Semantics + +### Inbound + +On each poll: + +1. load `get_updates_buf` +2. call `getupdates` +3. persist the new cursor if present +4. normalize inbound text messages into `IncomingMessage` +5. persist the latest `context_token` for that peer +6. emit the message to the agent + +### Outbound + +On response: + +1. read peer routing info from `metadata_json` +2. load the latest `context_token` +3. convert the response to plain text if needed +4. send one coalesced text reply via `sendmessage` + +This matches the upstream channel's block-send behavior. + +--- + +## Testing Plan + +### Unit tests + +- QR login response parsing +- `get_updates_buf` round-trip +- `context_token` round-trip +- inbound message normalization +- outbound metadata routing + +### Integration tests + +Use a mock iLink server to cover: + +- QR login success and expiry +- restart without re-login +- inbound poll -> agent -> outbound text reply +- cursor resume after restart + +--- + +## Phase 2 + +After Phase 1 is stable, add the upstream features we intentionally deferred: + +- multi-account support +- typing indicators +- media upload/send + +--- + +## Open Question + +- Should the public channel name remain `weixin`, or should we preserve an upstream-flavored alias for discoverability? diff --git a/registry/_bundles.json b/registry/_bundles.json index cea91551..0d7577e6 100644 --- a/registry/_bundles.json +++ b/registry/_bundles.json @@ -21,7 +21,8 @@ "channels/telegram", "channels/slack", "channels/whatsapp", - "channels/feishu" + "channels/feishu", + "channels/weixin" ], "shared_auth": null }, diff --git a/registry/channels/weixin.json b/registry/channels/weixin.json new file mode 100644 index 00000000..0bc0be08 --- /dev/null +++ b/registry/channels/weixin.json @@ -0,0 +1,32 @@ +{ + "name": "weixin", + "display_name": "Weixin Channel", + "kind": "channel", + "version": "0.1.0", + "wit_version": "0.3.0", + "description": "Talk to your agent through a Weixin iLink bot account", + "keywords": [ + "messaging", + "chat", + "weixin", + "wechat", + "qr" + ], + "source": { + "dir": "channels-src/weixin", + "capabilities": "weixin.capabilities.json", + "crate_name": "weixin-channel" + }, + "auth_summary": { + "method": "interactive", + "provider": "Weixin", + "secrets": [ + "weixin_bot_token" + ], + "shared_auth": null, + "setup_url": "https://ilinkai.weixin.qq.com" + }, + "tags": [ + "messaging" + ] +} diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index 7f0bb8fb..95346d04 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -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>, config_updates: &mut std::collections::HashMap, ) { @@ -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>, + config_updates: &mut std::collections::HashMap, +) { + 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 = 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(()) + } +} diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 31c2b296..8f15dc51 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -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>, + AuthenticatedUser(user): AuthenticatedUser, + Path(name): Path, + Json(_req): Json, +) -> Result, (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>, + AuthenticatedUser(user): AuthenticatedUser, + Path(name): Path, + Json(req): Json, +) -> Result, (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>, 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::>::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; diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 6b366482..f9cc418a 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -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) { diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 87afea87..957412c0 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -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; diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 3ac4163c..ec7418f0 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -536,6 +536,8 @@ pub struct ExtensionSetupResponse { pub kind: String, pub secrets: Vec, pub fields: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub interactive_login: Option, } #[derive(Debug, Serialize)] @@ -568,6 +570,32 @@ pub struct ExtensionSetupRequest { pub fields: std::collections::HashMap, } +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub qr_code_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub activated: Option, +} + #[derive(Debug, Serialize)] pub struct ActionResponse { pub success: bool, diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 0f308352..fede74eb 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -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, pub fields: Vec, + pub interactive_login: Option, } /// 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>, pending_telegram_verification: RwLock>, + pending_weixin_logins: RwLock>, #[cfg(test)] test_wasm_channel_loader: RwLock>, #[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 { + 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 { + 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 { + 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, diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 4c32767b..5e852254 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -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, +} + +/// 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, + /// Optional short instructions shown alongside the QR code. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, +} + +/// 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, + /// Whether the extension was successfully activated as part of login completion. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activated: Option, +} + /// Result of activating an extension. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActivateResult { diff --git a/src/extensions/weixin_login.rs b/src/extensions/weixin_login.rs new file mode 100644 index 00000000..f42098df --- /dev/null +++ b/src/extensions/weixin_login.rs @@ -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, + 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, + #[serde(default)] + ilink_bot_id: Option, + #[serde(default)] + baseurl: Option, +} + +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, +) { + 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 { + 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, +) -> Result { + 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 { + 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::() + .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 { + 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::() + .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()) + } + } + } +}