mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Add Weixin channel with QR login and web setup flow
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/*.wasm
|
||||
Generated
+408
@@ -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"
|
||||
@@ -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]
|
||||
Executable
+24
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Generated
+1
-1
@@ -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?
|
||||
@@ -21,7 +21,8 @@
|
||||
"channels/telegram",
|
||||
"channels/slack",
|
||||
"channels/whatsapp",
|
||||
"channels/feishu"
|
||||
"channels/feishu",
|
||||
"channels/weixin"
|
||||
],
|
||||
"shared_auth": null
|
||||
},
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user