Add Weixin channel with QR login and web setup flow

This commit is contained in:
Coffee
2026-03-25 13:40:03 +08:00
parent 82822d7b25
commit e30d9fe9db
23 changed files with 2578 additions and 16 deletions
+1
View File
@@ -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 |
+2
View File
@@ -0,0 +1,2 @@
/target
/*.wasm
+408
View File
@@ -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"
+23
View File
@@ -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]
+24
View File
@@ -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
+116
View File
@@ -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<GetUpdatesResponse, String> {
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(())
}
+5
View File
@@ -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";
+218
View File
@@ -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<ChannelConfig, String> {
let config = serde_json::from_str::<WeixinConfig>(&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::<OutboundMetadata>(&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);
+56
View File
@@ -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::<WeixinConfig>(&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::<String>(&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<String, String> {
channel_host::workspace_read(CONTEXT_TOKENS_PATH)
.and_then(|raw| serde_json::from_str::<HashMap<String, String>>(&raw).ok())
.unwrap_or_default()
}
pub fn persist_context_tokens(tokens: &HashMap<String, String>) -> 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");
}
+132
View File
@@ -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<i32>,
#[serde(default)]
pub errcode: Option<i32>,
#[serde(default)]
pub errmsg: Option<String>,
#[serde(default)]
pub msgs: Vec<WeixinMessage>,
#[serde(default)]
pub get_updates_buf: Option<String>,
}
#[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<MessageItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_token: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WeixinMessage {
#[serde(default)]
pub message_id: Option<i64>,
#[serde(default)]
pub from_user_id: Option<String>,
#[serde(default)]
pub to_user_id: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub message_type: Option<i32>,
#[serde(default)]
pub context_token: Option<String>,
#[serde(default)]
pub item_list: Vec<MessageItem>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MessageItem {
#[serde(default)]
pub r#type: Option<i32>,
#[serde(default)]
pub text_item: Option<TextItem>,
}
#[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<String>,
#[serde(default)]
pub message_id: Option<i64>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub context_token: Option<String>,
}
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;
@@ -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
}
}
+1 -1
View File
@@ -269,7 +269,7 @@ dependencies = [
[[package]]
name = "whatsapp-channel"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"serde",
"serde_json",
@@ -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 = <weixin sender id>` or owner scope if it is the bound owner
- `thread_id = Some("weixin:<sender_id>")`
- `conversation_scope_id = Some("weixin:<sender_id>")`
`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?
+2 -1
View File
@@ -21,7 +21,8 @@
"channels/telegram",
"channels/slack",
"channels/whatsapp",
"channels/feishu"
"channels/feishu",
"channels/weixin"
],
"shared_auth": null
},
+32
View File
@@ -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"
]
}
+107 -2
View File
@@ -190,7 +190,20 @@ async fn register_channel(
// The credential injection system only replaces placeholders in URLs
// and headers, so channels like Feishu that exchange app_id + app_secret
// for a tenant token need the raw values in their config.
inject_channel_secrets_into_config(&channel_name, secrets_store, &mut config_updates).await;
inject_channel_secrets_into_config(
&channel_name,
&config.owner_id,
secrets_store,
&mut config_updates,
)
.await;
inject_channel_settings_into_config(
&channel_name,
&config.owner_id,
settings_store,
&mut config_updates,
)
.await;
if !config_updates.is_empty() {
channel_arc.update_config(config_updates).await;
@@ -396,6 +409,7 @@ pub async fn inject_channel_credentials(
/// `feishu_app_secret` are injected as config keys `app_id` and `app_secret`.
async fn inject_channel_secrets_into_config(
channel_name: &str,
owner_id: &str,
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
) {
@@ -413,7 +427,7 @@ async fn inject_channel_secrets_into_config(
};
for &(config_key, secret_name) in secret_config_mappings {
match secrets.get_decrypted("default", secret_name).await {
match secrets.get_decrypted(owner_id, secret_name).await {
Ok(decrypted) => {
config_updates.insert(
config_key.to_string(),
@@ -442,3 +456,94 @@ async fn inject_channel_secrets_into_config(
}
}
}
/// Inject channel-specific settings into config for channels that persist
/// runtime-discovered values (for example a custom API base URL after login).
async fn inject_channel_settings_into_config(
channel_name: &str,
owner_id: &str,
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
config_updates: &mut std::collections::HashMap<String, serde_json::Value>,
) {
let Some(store) = settings_store else {
return;
};
let setting_mappings: &[(&str, &str)] = match channel_name {
"weixin" => &[("base_url", "extensions.weixin.base_url")],
_ => return,
};
for &(config_key, setting_path) in setting_mappings {
if let Ok(Some(serde_json::Value::String(value))) =
store.get_setting(owner_id, setting_path).await
{
let trimmed = value.trim();
if trimmed.is_empty() {
continue;
}
config_updates.insert(
config_key.to_string(),
serde_json::Value::String(trimmed.to_string()),
);
tracing::debug!(
channel = %channel_name,
config_key = %config_key,
setting_path = %setting_path,
"Injected setting into channel config"
);
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::db::{Database, SettingsStore};
#[tokio::test]
async fn test_inject_channel_settings_uses_owner_scope() -> Result<(), String> {
let dir = tempfile::tempdir().map_err(|e| format!("tempdir failed: {e}"))?;
let db_path = dir.path().join("weixin-settings.db");
let db = Arc::new(
crate::db::libsql::LibSqlBackend::new_local(&db_path)
.await
.map_err(|e| format!("create local libsql backend failed: {e}"))?,
);
db.run_migrations()
.await
.map_err(|e| format!("run libsql migrations failed: {e}"))?;
db.set_setting(
"default",
"extensions.weixin.base_url",
&serde_json::json!("https://default.example"),
)
.await
.map_err(|e| format!("persist default setting failed: {e}"))?;
db.set_setting(
"owner-123",
"extensions.weixin.base_url",
&serde_json::json!("https://owner.example"),
)
.await
.map_err(|e| format!("persist owner setting failed: {e}"))?;
let settings_store: Arc<dyn crate::db::SettingsStore> = db;
let mut config_updates = std::collections::HashMap::new();
super::inject_channel_settings_into_config(
"weixin",
"owner-123",
Some(&settings_store),
&mut config_updates,
)
.await;
assert_eq!(
config_updates.get("base_url"),
Some(&serde_json::json!("https://owner.example"))
);
Ok(())
}
}
+155
View File
@@ -469,6 +469,14 @@ pub async fn start_server(
"/api/extensions/{name}/setup",
get(extensions_setup_handler).post(extensions_setup_submit_handler),
)
.route(
"/api/extensions/{name}/login/start",
post(extensions_login_start_handler),
)
.route(
"/api/extensions/{name}/login/poll",
post(extensions_login_poll_handler),
)
// Pairing
.route("/api/pairing/{channel}", get(pairing_list_handler))
.route(
@@ -2437,9 +2445,93 @@ async fn extensions_setup_handler(
kind,
secrets: setup.secrets,
fields: setup.fields,
interactive_login: setup.interactive_login,
}))
}
async fn extensions_login_start_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Path(name): Path<String>,
Json(_req): Json<ExtensionInteractiveLoginStartRequest>,
) -> Result<Json<ExtensionInteractiveLoginResponse>, (StatusCode, String)> {
let ext_mgr = state.extension_manager.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Extension manager not available (secrets store required)".to_string(),
))?;
match ext_mgr.start_interactive_login(&name, &user.user_id).await {
Ok(result) => Ok(Json(ExtensionInteractiveLoginResponse {
success: true,
status: result.status,
message: result.message,
session_id: Some(result.session_id),
qr_code_url: result.qr_code_url,
instructions: result.instructions,
activated: None,
})),
Err(e) => Ok(Json(ExtensionInteractiveLoginResponse {
success: false,
status: "failed".to_string(),
message: e.to_string(),
session_id: None,
qr_code_url: None,
instructions: None,
activated: Some(false),
})),
}
}
async fn extensions_login_poll_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
Path(name): Path<String>,
Json(req): Json<ExtensionInteractiveLoginPollRequest>,
) -> Result<Json<ExtensionInteractiveLoginResponse>, (StatusCode, String)> {
let ext_mgr = state.extension_manager.as_ref().ok_or((
StatusCode::NOT_IMPLEMENTED,
"Extension manager not available (secrets store required)".to_string(),
))?;
match ext_mgr
.poll_interactive_login(&name, &req.session_id, &user.user_id)
.await
{
Ok(result) => {
if result.activated == Some(true) {
clear_auth_mode(&state, &user.user_id).await;
state.sse.broadcast_for_user(
&user.user_id,
SseEvent::AuthCompleted {
extension_name: name.clone(),
success: true,
message: result.message.clone(),
},
);
}
Ok(Json(ExtensionInteractiveLoginResponse {
success: result.status != "failed",
status: result.status,
message: result.message,
session_id: Some(result.session_id),
qr_code_url: result.qr_code_url,
instructions: None,
activated: result.activated,
}))
}
Err(e) => Ok(Json(ExtensionInteractiveLoginResponse {
success: false,
status: "failed".to_string(),
message: e.to_string(),
session_id: Some(req.session_id),
qr_code_url: None,
instructions: None,
activated: Some(false),
})),
}
}
async fn extensions_setup_submit_handler(
State(state): State<Arc<GatewayState>>,
AuthenticatedUser(user): AuthenticatedUser,
@@ -3003,6 +3095,69 @@ mod tests {
.with_state(state)
}
#[tokio::test]
async fn test_extensions_setup_returns_interactive_login_for_weixin() {
use axum::body::Body;
use tower::ServiceExt;
let secrets = test_secrets_store();
let (ext_mgr, _wasm_tools_dir, wasm_channels_dir) = test_ext_mgr(secrets);
std::fs::write(wasm_channels_dir.path().join("weixin.wasm"), b"\0asm fake")
.expect("write fake weixin wasm");
let caps = serde_json::json!({
"type": "channel",
"name": "weixin",
"setup": {
"required_secrets": [
{"name": "weixin_bot_token", "prompt": "Connect Weixin"}
]
}
});
std::fs::write(
wasm_channels_dir.path().join("weixin.capabilities.json"),
serde_json::to_string(&caps).expect("serialize weixin caps"),
)
.expect("write weixin capabilities");
let state = test_gateway_state(Some(ext_mgr));
let app = Router::new()
.route(
"/api/extensions/{name}/setup",
get(extensions_setup_handler),
)
.with_state(state);
let mut req = axum::http::Request::builder()
.method("GET")
.uri("/api/extensions/weixin/setup")
.body(Body::empty())
.expect("request");
req.extensions_mut().insert(UserIdentity {
user_id: "test".to_string(),
workspace_read_scopes: Vec::new(),
});
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
.await
.expect("response");
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
.await
.expect("body");
let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json response");
assert_eq!(parsed["name"], "weixin");
assert_eq!(parsed["interactive_login"]["method"], "qr_code");
assert_eq!(
parsed["interactive_login"]["button_label"],
"Connect Weixin"
);
assert_eq!(parsed["secrets"], serde_json::json!([]));
assert_eq!(parsed["fields"], serde_json::json!([]));
}
#[tokio::test]
async fn test_extensions_setup_submit_returns_failure_when_not_activated() {
use axum::body::Body;
+196 -10
View File
@@ -3056,16 +3056,17 @@ function showConfigureModal(name) {
.then((setup) => {
const secrets = Array.isArray(setup.secrets) ? setup.secrets : [];
const setupFields = Array.isArray(setup.fields) ? setup.fields : [];
if (secrets.length === 0 && setupFields.length === 0) {
const interactiveLogin = setup.interactive_login || null;
if (secrets.length === 0 && setupFields.length === 0 && !interactiveLogin) {
showToast('No configuration needed for ' + name, 'info');
return;
}
renderConfigureModal(name, secrets, setupFields);
renderConfigureModal(name, secrets, setupFields, interactiveLogin);
})
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
}
function renderConfigureModal(name, secrets, setupFields) {
function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
closeConfigureModal();
const overlay = document.createElement('div');
overlay.className = 'configure-overlay';
@@ -3091,6 +3092,13 @@ function renderConfigureModal(name, secrets, setupFields) {
modal.appendChild(hint);
}
if (interactiveLogin && interactiveLogin.instructions) {
const hint = document.createElement('div');
hint.className = 'configure-hint';
hint.textContent = interactiveLogin.instructions;
modal.appendChild(hint);
}
const form = document.createElement('div');
form.className = 'configure-form';
@@ -3180,7 +3188,13 @@ function renderConfigureModal(name, secrets, setupFields) {
fields.push({ kind: 'field', name: setupField.name, input: input });
}
modal.appendChild(form);
if (fields.length > 0) {
modal.appendChild(form);
}
if (interactiveLogin) {
modal.appendChild(renderInteractiveLoginPanel());
}
const error = document.createElement('div');
error.className = 'configure-inline-error';
@@ -3195,11 +3209,22 @@ function renderConfigureModal(name, secrets, setupFields) {
const actions = document.createElement('div');
actions.className = 'configure-actions';
const submitBtn = document.createElement('button');
submitBtn.className = 'btn-ext activate';
submitBtn.textContent = I18n.t('config.save');
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
actions.appendChild(submitBtn);
if (fields.length > 0) {
const submitBtn = document.createElement('button');
submitBtn.className = 'btn-ext activate';
submitBtn.textContent = I18n.t('config.save');
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
actions.appendChild(submitBtn);
}
if (interactiveLogin) {
const loginBtn = document.createElement('button');
loginBtn.className = 'btn-ext activate';
loginBtn.textContent = interactiveLogin.button_label || 'Connect';
loginBtn.dataset.interactiveLogin = 'true';
loginBtn.addEventListener('click', () => startInteractiveLogin(name, overlay));
actions.appendChild(loginBtn);
}
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-ext remove';
@@ -3211,7 +3236,168 @@ function renderConfigureModal(name, secrets, setupFields) {
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (fields.length > 0) fields[0].input.focus();
if (fields.length > 0) {
fields[0].input.focus();
} else {
const loginBtn = overlay.querySelector('.configure-actions button[data-interactive-login="true"]');
if (loginBtn) loginBtn.focus();
}
}
function renderInteractiveLoginPanel() {
const panel = document.createElement('div');
panel.className = 'configure-qr-login';
panel.style.display = 'none';
const title = document.createElement('div');
title.className = 'configure-verification-title';
title.textContent = 'Weixin QR Login';
panel.appendChild(title);
const status = document.createElement('div');
status.className = 'configure-verification-instructions';
status.textContent = 'Generate a QR code to connect this channel.';
status.dataset.qrStatus = 'true';
panel.appendChild(status);
const img = document.createElement('img');
img.className = 'configure-qr-image';
img.alt = 'Weixin QR code';
img.style.display = 'none';
img.dataset.qrImage = 'true';
panel.appendChild(img);
const link = document.createElement('a');
link.className = 'configure-verification-link';
link.textContent = 'Open QR code in a new tab';
link.target = '_blank';
link.rel = 'noreferrer noopener';
link.style.display = 'none';
link.dataset.qrLink = 'true';
panel.appendChild(link);
return panel;
}
function getInteractiveLoginButton(overlay) {
return overlay && overlay.querySelector('.configure-actions button[data-interactive-login="true"]');
}
function getInteractiveLoginPanel(overlay) {
return overlay && overlay.querySelector('.configure-qr-login');
}
function updateInteractiveLoginPanel(overlay, res) {
const panel = getInteractiveLoginPanel(overlay);
if (!panel) return;
const status = panel.querySelector('[data-qr-status="true"]');
const img = panel.querySelector('[data-qr-image="true"]');
const link = panel.querySelector('[data-qr-link="true"]');
panel.style.display = '';
if (status) {
status.textContent = res.message || '';
}
if (img && res.qr_code_url) {
img.src = res.qr_code_url;
img.style.display = '';
}
if (link && res.qr_code_url) {
link.href = res.qr_code_url;
link.style.display = '';
}
}
function setInteractiveLoginBusy(overlay, busy, label) {
const loginBtn = getInteractiveLoginButton(overlay);
if (!loginBtn) return;
loginBtn.disabled = !!busy;
if (label) {
loginBtn.textContent = label;
}
}
function startInteractiveLogin(name, overlay) {
if (!overlay || !document.body.contains(overlay)) return;
clearConfigureInlineError(overlay);
setConfigureInlineStatus(overlay, 'Generating Weixin QR code...');
setInteractiveLoginBusy(overlay, true, 'Waiting for scan...');
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/start', {
method: 'POST',
body: { force: true },
})
.then((res) => {
if (!overlay || !document.body.contains(overlay)) return;
if (!res.success || !res.session_id) {
setInteractiveLoginBusy(overlay, false, 'Connect Weixin');
setConfigureInlineError(overlay, res.message || 'Failed to start interactive login');
setConfigureInlineStatus(overlay, '');
return;
}
overlay.dataset.interactiveLoginSessionId = res.session_id;
updateInteractiveLoginPanel(overlay, res);
setConfigureInlineStatus(overlay, res.message || '');
pollInteractiveLogin(name, overlay, res.session_id);
})
.catch((err) => {
if (!overlay || !document.body.contains(overlay)) return;
setInteractiveLoginBusy(overlay, false, 'Connect Weixin');
setConfigureInlineError(overlay, err.message || 'Failed to start interactive login');
setConfigureInlineStatus(overlay, '');
});
}
function pollInteractiveLogin(name, overlay, sessionId) {
if (!overlay || !document.body.contains(overlay)) return;
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/poll', {
method: 'POST',
body: { session_id: sessionId },
})
.then((res) => {
if (!overlay || !document.body.contains(overlay)) return;
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
if (res.qr_code_url) {
updateInteractiveLoginPanel(overlay, res);
}
if (res.message) {
setConfigureInlineStatus(overlay, res.message);
}
if (res.status === 'pending' || res.status === 'scanned' || res.status === 'refreshed') {
if (res.status === 'refreshed') {
setInteractiveLoginBusy(overlay, true, 'Waiting for scan...');
}
window.setTimeout(function() {
pollInteractiveLogin(name, overlay, sessionId);
}, 0);
return;
}
if (res.success && res.activated) {
closeConfigureModal(name);
showToast(res.message || (name + ' connected successfully'), 'success');
refreshCurrentSettingsTab();
return;
}
setInteractiveLoginBusy(overlay, false, 'Connect Weixin');
setConfigureInlineError(overlay, res.message || 'Interactive login failed');
setConfigureInlineStatus(overlay, '');
})
.catch((err) => {
if (!overlay || !document.body.contains(overlay)) return;
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
setInteractiveLoginBusy(overlay, false, 'Connect Weixin');
setConfigureInlineError(overlay, err.message || 'Interactive login failed');
setConfigureInlineStatus(overlay, '');
});
}
function renderTelegramVerificationChallenge(overlay, verification) {
+20
View File
@@ -3238,6 +3238,26 @@ body {
border: 1px solid var(--border);
}
.configure-qr-login {
display: flex;
flex-direction: column;
gap: 12px;
margin: 16px 0 0 0;
padding: 12px;
border-radius: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border);
}
.configure-qr-image {
width: min(260px, 100%);
align-self: center;
border-radius: 10px;
background: white;
padding: 8px;
box-sizing: border-box;
}
.configure-verification-title {
font-size: var(--text-sm);
font-weight: 600;
+28
View File
@@ -536,6 +536,8 @@ pub struct ExtensionSetupResponse {
pub kind: String,
pub secrets: Vec<SecretFieldInfo>,
pub fields: Vec<SetupFieldInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interactive_login: Option<crate::extensions::InteractiveLoginInfo>,
}
#[derive(Debug, Serialize)]
@@ -568,6 +570,32 @@ pub struct ExtensionSetupRequest {
pub fields: std::collections::HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
pub struct ExtensionInteractiveLoginStartRequest {
#[serde(default)]
pub force: bool,
}
#[derive(Debug, Deserialize)]
pub struct ExtensionInteractiveLoginPollRequest {
pub session_id: String,
}
#[derive(Debug, Serialize)]
pub struct ExtensionInteractiveLoginResponse {
pub success: bool,
pub status: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub qr_code_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activated: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct ActionResponse {
pub success: bool,
+244 -2
View File
@@ -17,9 +17,16 @@ use crate::channels::wasm::{
use crate::channels::{ChannelManager, OutgoingResponse};
use crate::extensions::discovery::OnlineDiscovery;
use crate::extensions::registry::ExtensionRegistry;
use crate::extensions::weixin_login::{
PendingWeixinLogin, WEIXIN_BASE_URL_SETTING_PATH, WEIXIN_CHANNEL_NAME, WEIXIN_DEFAULT_BASE_URL,
WEIXIN_DEFAULT_BOT_TYPE, WeixinLoginPollOutcome,
interactive_login_info as weixin_interactive_login_info, poll_login as poll_weixin_login,
purge_expired_logins as purge_expired_weixin_logins, start_login as start_weixin_login,
};
use crate::extensions::{
ActivateResult, AuthResult, ConfigureResult, ExtensionError, ExtensionKind, ExtensionSource,
InstallResult, InstalledExtension, RegistryEntry, ResultSource, SearchResult, ToolAuthState,
InstallResult, InstalledExtension, InteractiveLoginInfo, InteractiveLoginPollResult,
InteractiveLoginStartResult, RegistryEntry, ResultSource, SearchResult, ToolAuthState,
UpgradeOutcome, UpgradeResult, VerificationChallenge,
};
use crate::hooks::HookRegistry;
@@ -95,6 +102,7 @@ struct ChannelRuntimeState {
pub struct ExtensionSetupSchema {
pub secrets: Vec<crate::channels::web::types::SecretFieldInfo>,
pub fields: Vec<crate::channels::web::types::SetupFieldInfo>,
pub interactive_login: Option<InteractiveLoginInfo>,
}
/// Only these global (non-namespaced) setting paths may be written by extension
@@ -429,6 +437,7 @@ pub struct ExtensionManager {
/// Set by the web gateway at startup via `enable_gateway_mode()`.
gateway_base_url: RwLock<Option<String>>,
pending_telegram_verification: RwLock<HashMap<String, PendingTelegramVerificationChallenge>>,
pending_weixin_logins: RwLock<HashMap<String, PendingWeixinLogin>>,
#[cfg(test)]
test_wasm_channel_loader: RwLock<Option<TestWasmChannelLoader>>,
#[cfg(test)]
@@ -542,6 +551,7 @@ impl ExtensionManager {
gateway_mode: std::sync::atomic::AtomicBool::new(false),
gateway_base_url: RwLock::new(None),
pending_telegram_verification: RwLock::new(HashMap::new()),
pending_weixin_logins: RwLock::new(HashMap::new()),
#[cfg(test)]
test_wasm_channel_loader: RwLock::new(None),
#[cfg(test)]
@@ -780,6 +790,16 @@ impl ExtensionManager {
overrides.insert("bot_username".to_string(), serde_json::json!(username));
}
if name == WEIXIN_CHANNEL_NAME
&& let Some(store) = self.store.as_ref()
&& let Ok(Some(serde_json::Value::String(base_url))) = store
.get_setting(&self.user_id, WEIXIN_BASE_URL_SETTING_PATH)
.await
&& !base_url.trim().is_empty()
{
overrides.insert("base_url".to_string(), serde_json::json!(base_url));
}
overrides
}
@@ -3529,6 +3549,15 @@ impl ExtensionManager {
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmChannel));
}
if name == WEIXIN_CHANNEL_NAME {
return Ok(AuthResult::awaiting_token(
name,
ExtensionKind::WasmChannel,
"Open the Weixin channel setup to scan a QR code and connect it.".to_string(),
cap_file.setup.setup_url.clone(),
));
}
// Prompt for the first missing secret
let secret = &missing[0];
Ok(AuthResult::awaiting_token(
@@ -4499,6 +4528,21 @@ impl ExtensionManager {
}
!expired
});
let mut weixin_logins = self.pending_weixin_logins.write().await;
purge_expired_weixin_logins(&mut weixin_logins);
}
fn interactive_login_info_for_extension(
name: &str,
kind: ExtensionKind,
) -> Option<InteractiveLoginInfo> {
match (kind, name) {
(ExtensionKind::WasmChannel, WEIXIN_CHANNEL_NAME) => {
Some(weixin_interactive_login_info())
}
_ => None,
}
}
/// Get the setup schema for an extension (secret/text fields and their status).
@@ -4518,6 +4562,10 @@ impl ExtensionManager {
return Ok(ExtensionSetupSchema {
secrets: Vec::new(),
fields: Vec::new(),
interactive_login: Self::interactive_login_info_for_extension(
name,
ExtensionKind::WasmChannel,
),
});
}
let cap_bytes = tokio::fs::read(&cap_path)
@@ -4527,6 +4575,14 @@ impl ExtensionManager {
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
if name == WEIXIN_CHANNEL_NAME {
return Ok(ExtensionSetupSchema {
secrets: Vec::new(),
fields: Vec::new(),
interactive_login: Some(weixin_interactive_login_info()),
});
}
let mut secrets = Vec::new();
for secret in &cap_file.setup.required_secrets {
let provided = self
@@ -4547,6 +4603,7 @@ impl ExtensionManager {
Ok(ExtensionSetupSchema {
secrets,
fields: Vec::new(),
interactive_login: None,
})
}
ExtensionKind::WasmTool => {
@@ -4554,6 +4611,7 @@ impl ExtensionManager {
return Ok(ExtensionSetupSchema {
secrets: Vec::new(),
fields: Vec::new(),
interactive_login: None,
});
};
@@ -4593,15 +4651,199 @@ impl ExtensionManager {
});
}
}
Ok(ExtensionSetupSchema { secrets, fields })
Ok(ExtensionSetupSchema {
secrets,
fields,
interactive_login: None,
})
}
_ => Ok(ExtensionSetupSchema {
secrets: Vec::new(),
fields: Vec::new(),
interactive_login: None,
}),
}
}
async fn resolve_weixin_base_url(&self, user_id: &str) -> String {
if let Some(store) = &self.store
&& let Ok(Some(serde_json::Value::String(value))) = store
.get_setting(user_id, WEIXIN_BASE_URL_SETTING_PATH)
.await
{
let trimmed = value.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", WEIXIN_CHANNEL_NAME));
if let Ok(cap_bytes) = tokio::fs::read(&cap_path).await
&& let Ok(cap_file) =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
&& let Some(value) = cap_file
.config
.get("base_url")
.and_then(|value| value.as_str())
{
let trimmed = value.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
WEIXIN_DEFAULT_BASE_URL.to_string()
}
async fn resolve_weixin_bot_type(&self) -> String {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", WEIXIN_CHANNEL_NAME));
if let Ok(cap_bytes) = tokio::fs::read(&cap_path).await
&& let Ok(cap_file) =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
&& let Some(value) = cap_file
.config
.get("bot_type")
.and_then(|value| value.as_str())
{
let trimmed = value.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
WEIXIN_DEFAULT_BOT_TYPE.to_string()
}
pub async fn start_interactive_login(
&self,
name: &str,
user_id: &str,
) -> Result<InteractiveLoginStartResult, ExtensionError> {
Self::validate_extension_name(name)?;
let kind = self.determine_installed_kind(name, user_id).await?;
if Self::interactive_login_info_for_extension(name, kind).is_none() {
return Err(ExtensionError::AuthNotSupported(format!(
"Interactive login is not supported for '{}'",
name
)));
}
if name != WEIXIN_CHANNEL_NAME {
return Err(ExtensionError::AuthNotSupported(format!(
"Interactive login is not implemented for '{}'",
name
)));
}
self.cleanup_expired_auths().await;
let base_url = self.resolve_weixin_base_url(user_id).await;
let bot_type = self.resolve_weixin_bot_type().await;
let (session, result) = start_weixin_login(user_id, &base_url, &bot_type).await?;
self.pending_weixin_logins
.write()
.await
.insert(session.session_id.clone(), session);
Ok(result)
}
pub async fn poll_interactive_login(
&self,
name: &str,
session_id: &str,
user_id: &str,
) -> Result<InteractiveLoginPollResult, ExtensionError> {
Self::validate_extension_name(name)?;
let kind = self.determine_installed_kind(name, user_id).await?;
if Self::interactive_login_info_for_extension(name, kind).is_none() {
return Err(ExtensionError::AuthNotSupported(format!(
"Interactive login is not supported for '{}'",
name
)));
}
if name != WEIXIN_CHANNEL_NAME {
return Err(ExtensionError::AuthNotSupported(format!(
"Interactive login is not implemented for '{}'",
name
)));
}
self.cleanup_expired_auths().await;
let mut sessions = self.pending_weixin_logins.write().await;
let Some(session) = sessions.get_mut(session_id) else {
return Err(ExtensionError::Other(
"This Weixin login session no longer exists. Start again.".to_string(),
));
};
if session.user_id != user_id {
return Err(ExtensionError::AuthFailed(
"This Weixin login session belongs to another user".to_string(),
));
}
let outcome = poll_weixin_login(session).await?;
match outcome {
WeixinLoginPollOutcome::Pending(result) => {
if matches!(result.status.as_str(), "failed") {
sessions.remove(session_id);
}
Ok(result)
}
WeixinLoginPollOutcome::Confirmed(confirmed) => {
sessions.remove(session_id);
drop(sessions);
if let Some(base_url) = confirmed.base_url.as_deref()
&& let Some(store) = &self.store
{
let _ = store
.set_setting(
user_id,
WEIXIN_BASE_URL_SETTING_PATH,
&serde_json::Value::String(base_url.to_string()),
)
.await;
}
let mut secrets = std::collections::HashMap::new();
secrets.insert("weixin_bot_token".to_string(), confirmed.bot_token);
let configure = self
.configure(name, &secrets, &std::collections::HashMap::new(), user_id)
.await?;
Ok(InteractiveLoginPollResult {
session_id: session_id.to_string(),
status: if configure.activated {
"succeeded".to_string()
} else {
"failed".to_string()
},
message: if configure.activated {
format!(
"Weixin connected as {}. {}",
confirmed.ilink_bot_id, configure.message
)
} else {
format!(
"Weixin login succeeded for {} but activation failed: {}",
confirmed.ilink_bot_id, configure.message
)
},
qr_code_url: None,
activated: Some(configure.activated),
})
}
}
}
async fn configure_telegram_binding(
&self,
name: &str,
+47
View File
@@ -19,6 +19,7 @@
pub mod discovery;
pub mod manager;
pub mod registry;
pub(crate) mod weixin_login;
pub use discovery::OnlineDiscovery;
pub use manager::ExtensionManager;
@@ -439,6 +440,52 @@ impl<'de> Deserialize<'de> for AuthResult {
}
}
/// Interactive login metadata surfaced to setup UIs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractiveLoginInfo {
/// Login method identifier (for example `qr_code`).
pub method: String,
/// User-facing button label.
pub button_label: String,
/// Optional short instructions shown above the login control.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
/// Result of starting an interactive extension login flow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractiveLoginStartResult {
/// Opaque session identifier used by follow-up poll requests.
pub session_id: String,
/// Flow status (`pending`, `error`).
pub status: String,
/// Human-readable message for the UI.
pub message: String,
/// Optional QR/image URL for browser display.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qr_code_url: Option<String>,
/// Optional short instructions shown alongside the QR code.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
}
/// Result of polling an interactive extension login flow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InteractiveLoginPollResult {
/// Session identifier associated with this poll result.
pub session_id: String,
/// Flow status (`pending`, `scanned`, `refreshed`, `succeeded`, `failed`).
pub status: String,
/// Human-readable message for the UI.
pub message: String,
/// Optional refreshed QR/image URL.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub qr_code_url: Option<String>,
/// Whether the extension was successfully activated as part of login completion.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activated: Option<bool>,
}
/// Result of activating an extension.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivateResult {
+446
View File
@@ -0,0 +1,446 @@
use std::time::{Duration, Instant};
use reqwest::Client;
use serde::Deserialize;
use uuid::Uuid;
use crate::extensions::{
ExtensionError, InteractiveLoginInfo, InteractiveLoginPollResult, InteractiveLoginStartResult,
};
pub(crate) const WEIXIN_CHANNEL_NAME: &str = "weixin";
pub(crate) const WEIXIN_BASE_URL_SETTING_PATH: &str = "extensions.weixin.base_url";
pub(crate) const WEIXIN_DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
pub(crate) const WEIXIN_DEFAULT_BOT_TYPE: &str = "3";
const LOGIN_SESSION_TTL: Duration = Duration::from_secs(5 * 60);
const QR_LONG_POLL_TIMEOUT: Duration = Duration::from_secs(35);
const QR_FETCH_TIMEOUT: Duration = Duration::from_secs(15);
const MAX_QR_REFRESH_COUNT: u8 = 3;
#[derive(Debug, Clone)]
pub(crate) struct PendingWeixinLogin {
pub user_id: String,
pub session_id: String,
pub qrcode: String,
pub qr_code_url: String,
pub started_at: Instant,
pub base_url: String,
pub bot_type: String,
pub refresh_count: u8,
}
impl PendingWeixinLogin {
pub fn is_fresh(&self) -> bool {
self.started_at.elapsed() < LOGIN_SESSION_TTL
}
}
#[derive(Debug, Clone)]
pub(crate) struct ConfirmedWeixinLogin {
pub bot_token: String,
pub base_url: Option<String>,
pub ilink_bot_id: String,
}
pub(crate) enum WeixinLoginPollOutcome {
Pending(InteractiveLoginPollResult),
Confirmed(ConfirmedWeixinLogin),
}
#[derive(Debug, Clone, Deserialize)]
struct QrCodeResponse {
qrcode: String,
qrcode_img_content: String,
}
#[derive(Debug, Clone, Deserialize)]
struct QrStatusResponse {
status: String,
#[serde(default)]
bot_token: Option<String>,
#[serde(default)]
ilink_bot_id: Option<String>,
#[serde(default)]
baseurl: Option<String>,
}
pub(crate) fn interactive_login_info() -> InteractiveLoginInfo {
InteractiveLoginInfo {
method: "qr_code".to_string(),
button_label: "Connect Weixin".to_string(),
instructions: Some("Scan the QR code with Weixin to connect this channel.".to_string()),
}
}
pub(crate) fn purge_expired_logins(
sessions: &mut std::collections::HashMap<String, PendingWeixinLogin>,
) {
sessions.retain(|_, session| session.is_fresh());
}
pub(crate) async fn start_login(
user_id: &str,
base_url: &str,
bot_type: &str,
) -> Result<(PendingWeixinLogin, InteractiveLoginStartResult), ExtensionError> {
let qr = fetch_qr_code(base_url, bot_type).await?;
Ok(build_pending_login(user_id, base_url, bot_type, qr))
}
pub(crate) async fn poll_login(
session: &mut PendingWeixinLogin,
) -> Result<WeixinLoginPollOutcome, ExtensionError> {
if !session.is_fresh() {
return Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "failed".to_string(),
message: "The QR code expired. Start a new Weixin connection.".to_string(),
qr_code_url: None,
activated: Some(false),
},
));
}
let status = poll_qr_status(&session.base_url, &session.qrcode).await?;
let refreshed_qr = if status.status == "expired" && session.refresh_count < MAX_QR_REFRESH_COUNT
{
Some(fetch_qr_code(&session.base_url, &session.bot_type).await?)
} else {
None
};
handle_poll_status(session, status, refreshed_qr)
}
fn build_pending_login(
user_id: &str,
base_url: &str,
bot_type: &str,
qr: QrCodeResponse,
) -> (PendingWeixinLogin, InteractiveLoginStartResult) {
let session_id = Uuid::new_v4().to_string();
let session = PendingWeixinLogin {
user_id: user_id.to_string(),
session_id: session_id.clone(),
qrcode: qr.qrcode,
qr_code_url: qr.qrcode_img_content.clone(),
started_at: Instant::now(),
base_url: base_url.to_string(),
bot_type: bot_type.to_string(),
refresh_count: 0,
};
let result = InteractiveLoginStartResult {
session_id,
status: "pending".to_string(),
message: "Scan the QR code in Weixin to finish connecting.".to_string(),
qr_code_url: Some(qr.qrcode_img_content),
instructions: Some(
"Keep this window open while you scan and confirm on your phone.".to_string(),
),
};
(session, result)
}
fn handle_poll_status(
session: &mut PendingWeixinLogin,
status: QrStatusResponse,
refreshed_qr: Option<QrCodeResponse>,
) -> Result<WeixinLoginPollOutcome, ExtensionError> {
match status.status.as_str() {
"wait" => Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "pending".to_string(),
message: "Waiting for the QR code to be scanned.".to_string(),
qr_code_url: None,
activated: None,
},
)),
"scaned" => Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "scanned".to_string(),
message: "QR code scanned. Confirm the login in Weixin.".to_string(),
qr_code_url: None,
activated: None,
},
)),
"expired" => {
session.refresh_count = session.refresh_count.saturating_add(1);
if session.refresh_count > MAX_QR_REFRESH_COUNT {
return Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "failed".to_string(),
message: "The QR code expired too many times. Start again.".to_string(),
qr_code_url: None,
activated: Some(false),
},
));
}
let refreshed = refreshed_qr.ok_or_else(|| {
ExtensionError::Other(
"Weixin QR status expired without a refreshed QR code".to_string(),
)
})?;
session.qrcode = refreshed.qrcode;
session.qr_code_url = refreshed.qrcode_img_content.clone();
session.started_at = Instant::now();
Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "refreshed".to_string(),
message: "The QR code expired, so a fresh one was generated.".to_string(),
qr_code_url: Some(refreshed.qrcode_img_content),
activated: None,
},
))
}
"confirmed" => {
let bot_token = status.bot_token.filter(|token| !token.trim().is_empty());
let ilink_bot_id = status
.ilink_bot_id
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
ExtensionError::Other(
"Weixin login succeeded but no bot account id was returned".to_string(),
)
})?;
let bot_token = bot_token.ok_or_else(|| {
ExtensionError::Other(
"Weixin login succeeded but no bot token was returned".to_string(),
)
})?;
Ok(WeixinLoginPollOutcome::Confirmed(ConfirmedWeixinLogin {
bot_token,
base_url: status.baseurl.filter(|value| !value.trim().is_empty()),
ilink_bot_id,
}))
}
other => {
tracing::warn!(status = other, "Unexpected Weixin QR status");
Ok(WeixinLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "failed".to_string(),
message: format!("Unexpected Weixin login status: {other}"),
qr_code_url: None,
activated: Some(false),
},
))
}
}
}
fn ensure_trailing_slash(base_url: &str) -> String {
if base_url.ends_with('/') {
base_url.to_string()
} else {
format!("{base_url}/")
}
}
async fn fetch_qr_code(base_url: &str, bot_type: &str) -> Result<QrCodeResponse, ExtensionError> {
let base = ensure_trailing_slash(base_url);
let url = format!(
"{base}ilink/bot/get_bot_qrcode?bot_type={}",
urlencoding::encode(bot_type)
);
let client = Client::builder()
.timeout(QR_FETCH_TIMEOUT)
.build()
.map_err(|e| ExtensionError::Other(format!("Failed to create Weixin login client: {e}")))?;
let response = client
.get(&url)
.send()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to fetch Weixin QR code: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(status = %status, "Weixin QR code request failed");
return Err(ExtensionError::Other(format!(
"Weixin QR code request failed with {status}: {body}"
)));
}
response
.json::<QrCodeResponse>()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to parse Weixin QR code response: {e}")))
}
async fn poll_qr_status(base_url: &str, qrcode: &str) -> Result<QrStatusResponse, ExtensionError> {
let base = ensure_trailing_slash(base_url);
let url = format!(
"{base}ilink/bot/get_qrcode_status?qrcode={}",
urlencoding::encode(qrcode)
);
let client = Client::builder()
.timeout(QR_LONG_POLL_TIMEOUT)
.build()
.map_err(|e| ExtensionError::Other(format!("Failed to create Weixin poll client: {e}")))?;
let response = client
.get(&url)
.header("iLink-App-ClientVersion", "1")
.send()
.await;
let response = match response {
Ok(response) => response,
Err(error) if error.is_timeout() => {
return Ok(QrStatusResponse {
status: "wait".to_string(),
bot_token: None,
ilink_bot_id: None,
baseurl: None,
});
}
Err(error) => {
return Err(ExtensionError::Other(format!(
"Failed to poll Weixin QR status: {error}"
)));
}
};
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(status = %status, "Weixin QR status poll failed");
return Err(ExtensionError::Other(format!(
"Weixin QR status poll failed with {status}: {body}"
)));
}
response
.json::<QrStatusResponse>()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to parse Weixin QR status: {e}")))
}
#[cfg(test)]
mod tests {
use super::{
QrCodeResponse, QrStatusResponse, WeixinLoginPollOutcome, build_pending_login,
handle_poll_status,
};
#[test]
fn test_build_pending_login_returns_qr_state_and_result() {
let (session, start_result) = build_pending_login(
"owner",
"https://ilink.example",
"3",
QrCodeResponse {
qrcode: "qr-123".to_string(),
qrcode_img_content: "https://qr.example/one".to_string(),
},
);
assert_eq!(session.user_id, "owner");
assert_eq!(session.base_url, "https://ilink.example");
assert_eq!(session.bot_type, "3");
assert_eq!(session.qrcode, "qr-123");
assert_eq!(session.qr_code_url, "https://qr.example/one");
assert_eq!(start_result.status, "pending");
assert_eq!(
start_result.qr_code_url.as_deref(),
Some("https://qr.example/one")
);
assert_eq!(start_result.session_id, session.session_id);
}
#[test]
fn test_handle_poll_status_confirms_login() -> Result<(), String> {
let (mut session, _) = build_pending_login(
"owner",
"https://ilink.example",
"3",
QrCodeResponse {
qrcode: "qr-123".to_string(),
qrcode_img_content: "https://qr.example/one".to_string(),
},
);
let outcome = handle_poll_status(
&mut session,
QrStatusResponse {
status: "confirmed".to_string(),
bot_token: Some("bot-token-123".to_string()),
ilink_bot_id: Some("wx-bot-1".to_string()),
baseurl: Some("https://override.example".to_string()),
},
None,
)
.map_err(|e| e.to_string())?;
match outcome {
WeixinLoginPollOutcome::Confirmed(confirmed) => {
assert_eq!(confirmed.bot_token, "bot-token-123");
assert_eq!(confirmed.ilink_bot_id, "wx-bot-1");
assert_eq!(
confirmed.base_url.as_deref(),
Some("https://override.example")
);
Ok(())
}
WeixinLoginPollOutcome::Pending(result) => Err(format!(
"expected confirmed login, got pending status {}",
result.status
)),
}
}
#[test]
fn test_handle_poll_status_refreshes_expired_qr() -> Result<(), String> {
let (mut session, _) = build_pending_login(
"owner",
"https://ilink.example",
"3",
QrCodeResponse {
qrcode: "qr-initial".to_string(),
qrcode_img_content: "https://qr.example/initial".to_string(),
},
);
let outcome = handle_poll_status(
&mut session,
QrStatusResponse {
status: "expired".to_string(),
bot_token: None,
ilink_bot_id: None,
baseurl: None,
},
Some(QrCodeResponse {
qrcode: "qr-refreshed".to_string(),
qrcode_img_content: "https://qr.example/refreshed".to_string(),
}),
)
.map_err(|e| e.to_string())?;
match outcome {
WeixinLoginPollOutcome::Pending(result) => {
assert_eq!(result.status, "refreshed");
assert_eq!(
result.qr_code_url.as_deref(),
Some("https://qr.example/refreshed")
);
assert_eq!(session.qrcode, "qr-refreshed");
assert_eq!(session.refresh_count, 1);
Ok(())
}
WeixinLoginPollOutcome::Confirmed(_) => {
Err("expected QR refresh before confirmation".to_string())
}
}
}
}