Compare commits

..
53 changed files with 555 additions and 7623 deletions
Generated
+24 -145
View File
@@ -121,15 +121,6 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "ansi_term"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
dependencies = [
"winapi",
]
[[package]]
name = "anstream"
version = "0.6.21"
@@ -166,7 +157,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -177,7 +168,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -401,17 +392,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi 0.1.19",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -961,29 +941,6 @@ dependencies = [
"serde",
]
[[package]]
name = "bindgen"
version = "0.59.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bd2a9a458e8f4304c52c43ebb0cfbd520289f8379a52e329a38afda99bf8eb8"
dependencies = [
"bitflags 1.3.2",
"cexpr",
"clang-sys",
"clap 2.34.0",
"env_logger",
"lazy_static",
"lazycell",
"log",
"peeking_take_while",
"proc-macro2",
"quote",
"regex",
"rustc-hash 1.1.0",
"shlex",
"which",
]
[[package]]
name = "bindgen"
version = "0.66.1"
@@ -1403,21 +1360,6 @@ dependencies = [
"libloading",
]
[[package]]
name = "clap"
version = "2.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
dependencies = [
"ansi_term",
"atty",
"bitflags 1.3.2",
"strsim 0.8.0",
"textwrap",
"unicode-width 0.1.14",
"vec_map",
]
[[package]]
name = "clap"
version = "4.5.60"
@@ -1437,7 +1379,7 @@ dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim 0.11.1",
"strsim",
]
[[package]]
@@ -1446,7 +1388,7 @@ version = "4.5.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c757a3b7e39161a4e56f9365141ada2a6c915a8622c408ab6bb4b5d047371031"
dependencies = [
"clap 4.5.60",
"clap",
]
[[package]]
@@ -1755,7 +1697,7 @@ dependencies = [
"anes",
"cast",
"ciborium",
"clap 4.5.60",
"clap",
"criterion-plot",
"is-terminal",
"itertools 0.10.5",
@@ -1998,7 +1940,7 @@ dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"strsim",
"syn 2.0.117",
]
@@ -2194,7 +2136,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2368,19 +2310,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_logger"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7"
dependencies = [
"atty",
"humantime",
"log",
"regex",
"termcolor",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -2394,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2919,15 +2848,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "hermit-abi"
version = "0.5.2"
@@ -3088,12 +3008,6 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "hyper"
version = "0.14.32"
@@ -3236,7 +3150,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"system-configuration",
"tokio",
"tower-service",
@@ -3478,7 +3392,6 @@ dependencies = [
name = "ironclaw"
version = "0.22.0"
dependencies = [
"aes",
"aes-gcm",
"aho-corasick",
"anyhow",
@@ -3493,7 +3406,7 @@ dependencies = [
"bytes",
"chrono",
"chrono-tz",
"clap 4.5.60",
"clap",
"clap_complete",
"criterion",
"cron",
@@ -3545,7 +3458,6 @@ dependencies = [
"serde_json",
"serde_yml",
"sha2",
"silk-rs",
"subtle",
"tar",
"tempfile",
@@ -3609,9 +3521,9 @@ version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi 0.5.2",
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3845,7 +3757,7 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f2a50a585a1184a43621a9133b7702ba5cb7a87ca5e704056b19d8005de6faf"
dependencies = [
"bindgen 0.66.1",
"bindgen",
"cc",
]
@@ -4231,7 +4143,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -4319,7 +4231,7 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi 0.5.2",
"hermit-abi",
"libc",
]
@@ -4797,7 +4709,7 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi 0.5.2",
"hermit-abi",
"pin-project-lite",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -5017,7 +4929,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.37",
"socket2 0.6.3",
"socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5054,9 +4966,9 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5569,7 +5481,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -6195,18 +6107,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "silk-rs"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "014e6619f35a385ff848570e73a0b8c36b31031e0ee11cac70192b50097b1cfe"
dependencies = [
"bindgen 0.59.2",
"bytes",
"cc",
"thiserror 1.0.69",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
@@ -6263,7 +6163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6335,12 +6235,6 @@ dependencies = [
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -6494,7 +6388,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -6581,15 +6475,6 @@ dependencies = [
"testcontainers",
]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width 0.1.14",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -7303,7 +7188,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -7456,12 +7341,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vec_map"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -8159,7 +8038,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
-2
View File
@@ -138,7 +138,6 @@ wasmtime-wasi = "28" # WASI support for component model
wasmparser = "0.220" # WASM binary parsing for validation
# Cryptography for secrets management
aes = "0.8"
aes-gcm = "0.10"
hkdf = "0.12"
hmac = "0.12"
@@ -175,7 +174,6 @@ base64 = "0.22.1"
mime_guess = "2.0.5"
clap_complete = "4.5.0"
lru = "0.16.3"
silk-rs = "0.2.0"
# HTML to Markdown conversion (feature gated)
html-to-markdown-rs = { version = "2.3", optional = true }
-1
View File
@@ -77,7 +77,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
| Feishu/Lark | ✅ | 🚧 | P3 | WASM channel with Event Subscription v2.0; Bitable/Docx tools planned |
| LINE | ✅ | ❌ | P3 | |
| WeChat (iLink bot) | ✅ | 🚧 | P2 | Extension-first channel (`channels-src/wechat`), single-account DM flow with QR login, typing, image send/receive, inbound file extraction, and inbound voice handling with SILK-to-WAV fallback; multi-account plus video and outbound file parity follow-up |
| WebChat | ✅ | ✅ | - | Web gateway chat |
| Matrix | ✅ | ❌ | P3 | E2EE support |
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker |
-2
View File
@@ -1,2 +0,0 @@
/target
/*.wasm
-568
View File
@@ -1,568 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[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 = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[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 = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[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 = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[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 = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[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 = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[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 = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[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 = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[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 = "wechat-channel"
version = "0.1.0"
dependencies = [
"aes",
"base64",
"cipher",
"md-5",
"rand",
"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"
-27
View File
@@ -1,27 +0,0 @@
[package]
name = "wechat-channel"
version = "0.1.0"
edition = "2021"
description = "WeChat 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"
aes = "0.8"
cipher = "0.4"
md-5 = "0.10"
rand = "0.8"
[profile.release]
opt-level = "s"
lto = true
strip = true
codegen-units = 1
[workspace]
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "Building WeChat channel WASM component..."
cargo build --release --target wasm32-wasip2
WASM_PATH="target/wasm32-wasip2/release/wechat_channel.wasm"
if [ -f "$WASM_PATH" ]; then
if command -v wasm-tools >/dev/null 2>&1; then
wasm-tools component new "$WASM_PATH" -o wechat.wasm 2>/dev/null || cp "$WASM_PATH" wechat.wasm
wasm-tools strip wechat.wasm -o wechat.wasm
else
cp "$WASM_PATH" wechat.wasm
echo "wasm-tools not found; copied raw wasm output without component conversion/strip"
fi
echo "Built: wechat.wasm ($(du -h wechat.wasm | cut -f1))"
echo ""
echo "To install:"
echo " mkdir -p ~/.ironclaw/channels"
echo " cp wechat.wasm wechat.capabilities.json ~/.ironclaw/channels/"
else
echo "Error: WASM output not found at $WASM_PATH"
exit 1
fi
-277
View File
@@ -1,277 +0,0 @@
use base64::Engine as _;
use crate::near::agent::channel_host;
use crate::types::{
BaseInfo, GetConfigRequest, GetConfigResponse, GetUpdatesRequest, GetUpdatesResponse,
GetUploadUrlRequest, GetUploadUrlResponse, MessageItem, OutboundWechatMessage,
SendMessageRequest, SendTypingRequest, SendTypingResponse, TextItem, WechatConfig,
MESSAGE_ITEM_TEXT, MESSAGE_STATE_FINISH, MESSAGE_TYPE_BOT,
};
pub 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 {WECHAT_BOT_TOKEN}",
"Content-Length": body.len().to_string(),
"X-WECHAT-UIN": random_wechat_uin(),
})
.to_string()
}
fn summarize_body_preview(bytes: &[u8], limit: usize) -> String {
let preview = String::from_utf8_lossy(&bytes[..bytes.len().min(limit)]);
let normalized = preview.replace(['\n', '\r'], " ");
if bytes.len() > limit {
format!("{normalized}...")
} else {
normalized
}
}
pub fn get_updates(
config: &WechatConfig,
get_updates_buf: &str,
) -> Result<GetUpdatesResponse, String> {
get_updates_with_timeout(config, get_updates_buf, config.long_poll_timeout_ms)
}
pub fn get_updates_with_timeout(
config: &WechatConfig,
get_updates_buf: &str,
timeout_ms: u32,
) -> 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)
);
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"WeChat getUpdates request: cursor_len={} timeout_ms={}",
get_updates_buf.len(),
config.long_poll_timeout_ms
),
);
let response =
channel_host::http_request("POST", &url, &headers, Some(&body), Some(timeout_ms))
.map_err(|e| format!("getUpdates request failed: {e}"))?;
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"WeChat getUpdates response: status={} bytes={} has_image_marker={} has_aeskey_marker={} preview={}",
response.status,
response.body.len(),
response
.body
.windows(b"image_item".len())
.any(|window| window == b"image_item"),
response
.body
.windows(b"aeskey".len())
.any(|window| window == b"aeskey"),
summarize_body_preview(&response.body, 160)
),
);
if response.status != 200 {
let body = String::from_utf8_lossy(&response.body);
return Err(format!("getUpdates returned {}: {}", response.status, body));
}
let parsed: GetUpdatesResponse = serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse getUpdates response: {e}"))?;
channel_host::log(
channel_host::LogLevel::Info,
&format!(
"WeChat getUpdates parsed: ret={:?} errcode={:?} msg_count={} next_cursor_len={}",
parsed.ret,
parsed.errcode,
parsed.msgs.len(),
parsed.get_updates_buf.as_deref().unwrap_or_default().len()
),
);
Ok(parsed)
}
pub fn send_text_message(
config: &WechatConfig,
to_user_id: &str,
text: &str,
context_token: Option<&str>,
) -> Result<(), String> {
let message = SendMessageRequest {
msg: OutboundWechatMessage {
from_user_id: String::new(),
to_user_id: to_user_id.to_string(),
client_id: format!("wechat-{}", 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(),
}),
image_item: None,
voice_item: None,
file_item: None,
}],
context_token: context_token.map(str::to_string),
},
base_info: base_info(),
};
send_message_request(config, &message)
}
pub fn send_message_request(
config: &WechatConfig,
message: &SendMessageRequest,
) -> Result<(), String> {
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(())
}
pub fn get_upload_url(
config: &WechatConfig,
request: &GetUploadUrlRequest,
) -> Result<GetUploadUrlResponse, String> {
let body = serde_json::to_vec(request)
.map_err(|e| format!("Failed to encode getUploadUrl request: {e}"))?;
let headers = request_headers(&body);
let url = format!(
"{}ilink/bot/getuploadurl",
ensure_trailing_slash(&config.base_url)
);
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(15_000))
.map_err(|e| format!("getUploadUrl request failed: {e}"))?;
if response.status != 200 {
let body = String::from_utf8_lossy(&response.body);
return Err(format!(
"getUploadUrl returned {}: {}",
response.status, body
));
}
serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse getUploadUrl response: {e}"))
}
pub fn get_config(
config: &WechatConfig,
ilink_user_id: &str,
context_token: Option<&str>,
) -> Result<GetConfigResponse, String> {
let body = serde_json::to_vec(&GetConfigRequest {
ilink_user_id: ilink_user_id.to_string(),
context_token: context_token.map(str::to_string),
base_info: base_info(),
})
.map_err(|e| format!("Failed to encode getConfig request: {e}"))?;
let headers = request_headers(&body);
let url = format!(
"{}ilink/bot/getconfig",
ensure_trailing_slash(&config.base_url)
);
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(10_000))
.map_err(|e| format!("getConfig request failed: {e}"))?;
if response.status != 200 {
let body = String::from_utf8_lossy(&response.body);
return Err(format!("getConfig returned {}: {}", response.status, body));
}
serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse getConfig response: {e}"))
}
pub fn send_typing(
config: &WechatConfig,
ilink_user_id: &str,
typing_ticket: &str,
status: i32,
) -> Result<(), String> {
let body = serde_json::to_vec(&SendTypingRequest {
ilink_user_id: ilink_user_id.to_string(),
typing_ticket: typing_ticket.to_string(),
status,
base_info: base_info(),
})
.map_err(|e| format!("Failed to encode sendTyping request: {e}"))?;
let headers = request_headers(&body);
let url = format!(
"{}ilink/bot/sendtyping",
ensure_trailing_slash(&config.base_url)
);
let response = channel_host::http_request("POST", &url, &headers, Some(&body), Some(10_000))
.map_err(|e| format!("sendTyping request failed: {e}"))?;
if response.status != 200 {
let body = String::from_utf8_lossy(&response.body);
return Err(format!("sendTyping returned {}: {}", response.status, body));
}
let parsed: SendTypingResponse = serde_json::from_slice(&response.body)
.map_err(|e| format!("Failed to parse sendTyping response: {e}"))?;
if parsed.ret.unwrap_or(0) != 0 {
let errmsg = parsed
.errmsg
.as_deref()
.unwrap_or("unknown WeChat sendTyping error");
return Err(format!(
"sendTyping returned ret={} errmsg={errmsg}",
parsed.ret.unwrap_or(-1)
));
}
Ok(())
}
-6
View File
@@ -1,6 +0,0 @@
pub const TOKEN_SECRET_NAME: &str = "wechat_bot_token";
pub const CONFIG_PATH: &str = "config.json";
pub const GET_UPDATES_BUF_PATH: &str = "state/get_updates_buf.json";
pub const CONTEXT_TOKENS_PATH: &str = "state/context_tokens.json";
pub const TYPING_TICKETS_PATH: &str = "state/typing_tickets.json";
pub const PENDING_INBOUND_PATH: &str = "state/pending_inbound.json";
-942
View File
@@ -1,942 +0,0 @@
wit_bindgen::generate!({
world: "sandboxed-channel",
path: "../../wit/channel.wit",
});
mod api;
mod auth;
mod media;
mod state;
mod types;
use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, PollConfig, StatusType, StatusUpdate,
};
use near::agent::channel_host::{self, EmittedMessage};
use serde_json::json;
use crate::auth::TOKEN_SECRET_NAME;
use crate::state::{
load_config, load_context_tokens, load_get_updates_buf, load_pending_inbound_bundles,
load_typing_tickets, persist_config, persist_context_tokens, persist_get_updates_buf,
persist_pending_inbound_bundles, persist_typing_tickets, PendingInboundBundle,
StoredInboundAttachment, TypingTicketEntry,
};
use crate::types::{
OutboundMetadata, WechatConfig, WechatMessage, MESSAGE_ITEM_TEXT, MESSAGE_TYPE_USER,
TYPING_STATUS_CANCEL, TYPING_STATUS_TYPING,
};
const TYPING_TICKET_TTL_MS: u64 = 24 * 60 * 60 * 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WechatStatusAction {
Typing,
Cancel,
}
struct WechatChannel;
impl Guest for WechatChannel {
fn on_start(config_json: String) -> Result<ChannelConfig, String> {
let config = serde_json::from_str::<WechatConfig>(&config_json)
.map_err(|e| format!("Failed to parse WeChat config: {e}"))?;
persist_config(&config)?;
Ok(ChannelConfig {
display_name: "WeChat".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\":\"wechat channel does not expose webhooks\"}".to_vec(),
}
}
fn on_poll() {
if !channel_host::secret_exists(TOKEN_SECRET_NAME) {
channel_host::log(
channel_host::LogLevel::Warn,
"WeChat bot token is missing; skipping poll",
);
return;
}
let config = load_config();
let cursor = load_get_updates_buf();
let mut current_cursor = cursor.clone();
let mut context_tokens = load_context_tokens();
let mut pending_inbound = match load_pending_inbound_bundles() {
Ok(bundles) => bundles,
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to load WeChat pending inbound bundles: {error}"),
);
return;
}
};
let mut pending_inbound_changed = false;
for bundle in take_due_pending_bundles(&mut pending_inbound, channel_host::now_millis()) {
pending_inbound_changed = true;
emit_buffered_bundle(bundle);
}
match api::get_updates(&config, &current_cursor) {
Ok(response) => {
if response.errcode == Some(-14) {
channel_host::log(
channel_host::LogLevel::Error,
"WeChat getUpdates returned errcode=-14; reconnect the channel",
);
return;
}
if response.ret.unwrap_or(0) != 0 {
let errmsg = response
.errmsg
.as_deref()
.unwrap_or("unknown WeChat polling error");
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"WeChat getUpdates returned ret={} errmsg={errmsg}",
response.ret.unwrap_or(-1)
),
);
}
if let Some(next_cursor) = response.get_updates_buf.as_deref() {
if next_cursor != current_cursor {
current_cursor = next_cursor.to_string();
if let Err(error) = persist_get_updates_buf(next_cursor) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist WeChat 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;
}
}
match incoming_bundle_from_message(&config, message) {
Ok(Some(bundle)) => {
let emitted = process_incoming_bundle(
&mut pending_inbound,
bundle,
&mut pending_inbound_changed,
channel_host::now_millis(),
u64::from(config.inbound_merge_window_ms),
);
for emitted_bundle in emitted {
emit_buffered_bundle(emitted_bundle);
}
}
Ok(None) => {}
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to map WeChat inbound message: {error}"),
);
}
}
}
collect_follow_up_bundles(
&config,
&mut current_cursor,
&mut context_tokens,
&mut context_tokens_changed,
&mut pending_inbound,
&mut pending_inbound_changed,
);
for bundle in
take_due_pending_bundles(&mut pending_inbound, channel_host::now_millis())
{
pending_inbound_changed = true;
emit_buffered_bundle(bundle);
}
if context_tokens_changed {
if let Err(error) = persist_context_tokens(&context_tokens) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist WeChat context tokens: {error}"),
);
}
}
if pending_inbound_changed {
if let Err(error) = persist_pending_inbound_bundles(&pending_inbound) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist WeChat pending inbound bundles: {error}"),
);
}
}
}
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("WeChat 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 WeChat 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());
if let Err(error) = send_typing_indicator(
&config,
&metadata,
context_token.as_deref(),
TYPING_STATUS_CANCEL,
false,
) {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("Failed to cancel WeChat typing indicator before reply: {error}"),
);
}
send_response(&config, &metadata, &response, context_token.as_deref())
}
fn on_status(update: StatusUpdate) {
let Some(action) = classify_status_update(&update) else {
return;
};
let metadata = match serde_json::from_str::<OutboundMetadata>(&update.metadata_json) {
Ok(metadata) => metadata,
Err(_) => {
channel_host::log(
channel_host::LogLevel::Debug,
"on_status: no valid WeChat metadata, skipping typing update",
);
return;
}
};
let config = load_config();
let context_tokens = load_context_tokens();
let context_token = resolve_context_token(&metadata, &context_tokens);
let (typing_status, allow_ticket_fetch) = match action {
WechatStatusAction::Typing => (TYPING_STATUS_TYPING, true),
WechatStatusAction::Cancel => (TYPING_STATUS_CANCEL, false),
};
if let Err(error) = send_typing_indicator(
&config,
&metadata,
context_token.as_deref(),
typing_status,
allow_ticket_fetch,
) {
channel_host::log(
channel_host::LogLevel::Debug,
&format!("WeChat typing update failed: {error}"),
);
}
}
fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> {
Ok(())
}
fn on_shutdown() {}
}
fn incoming_bundle_from_message(
config: &WechatConfig,
message: WechatMessage,
) -> Result<Option<PendingInboundBundle>, String> {
if message.message_type != Some(MESSAGE_TYPE_USER) {
return Ok(None);
}
let from_user_id = match message.from_user_id.as_deref() {
Some(user_id) => user_id,
None => return Ok(None),
};
let text = extract_text(&message);
let attachments = media::extract_inbound_attachments(config, &message)?
.into_iter()
.map(StoredInboundAttachment::from)
.collect::<Vec<_>>();
if text.trim().is_empty() && attachments.is_empty() {
return Ok(None);
}
Ok(Some(PendingInboundBundle {
from_user_id: from_user_id.to_string(),
to_user_id: message.to_user_id,
session_id: message.session_id,
context_token: message.context_token,
message_id: message.message_id,
flush_at_ms: 0,
text,
attachments,
}))
}
fn process_incoming_bundle(
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
mut bundle: PendingInboundBundle,
pending_inbound_changed: &mut bool,
now_ms: u64,
inbound_merge_window_ms: u64,
) -> Vec<PendingInboundBundle> {
let key = bundle.from_user_id.clone();
let bundle_has_text = !bundle.text.trim().is_empty();
let bundle_has_attachments = !bundle.attachments.is_empty();
if let Some(mut pending) = pending_inbound.remove(&key) {
*pending_inbound_changed = true;
if bundle_has_text {
let incoming_metadata = bundle.clone();
pending.text = merge_text(&pending.text, &bundle.text);
pending.attachments.extend(bundle.attachments);
merge_bundle_metadata(&mut pending, &incoming_metadata);
return vec![pending];
}
let incoming_metadata = bundle.clone();
pending.attachments.extend(bundle.attachments);
merge_bundle_metadata(&mut pending, &incoming_metadata);
pending.flush_at_ms = next_flush_deadline(now_ms, inbound_merge_window_ms);
pending_inbound.insert(key, pending);
return Vec::new();
}
if bundle_has_attachments && !bundle_has_text {
*pending_inbound_changed = true;
bundle.flush_at_ms = next_flush_deadline(now_ms, inbound_merge_window_ms);
pending_inbound.insert(key, bundle);
Vec::new()
} else {
vec![bundle]
}
}
fn collect_follow_up_bundles(
config: &WechatConfig,
current_cursor: &mut String,
context_tokens: &mut std::collections::HashMap<String, String>,
context_tokens_changed: &mut bool,
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
pending_inbound_changed: &mut bool,
) {
while !pending_inbound.is_empty() {
let now_ms = channel_host::now_millis();
let Some(timeout_ms) = next_follow_up_timeout_ms(pending_inbound, now_ms) else {
break;
};
if timeout_ms == 0 {
break;
}
let timeout_ms_u32 = timeout_ms.min(u64::from(u32::MAX)) as u32;
let response = match api::get_updates_with_timeout(config, current_cursor, timeout_ms_u32) {
Ok(response) => response,
Err(_) => break,
};
if response.errcode == Some(-14) {
channel_host::log(
channel_host::LogLevel::Error,
"WeChat getUpdates returned errcode=-14 during follow-up merge window; reconnect the channel",
);
break;
}
if response.ret.unwrap_or(0) != 0 {
let errmsg = response
.errmsg
.as_deref()
.unwrap_or("unknown WeChat polling error");
channel_host::log(
channel_host::LogLevel::Warn,
&format!(
"WeChat getUpdates returned ret={} errmsg={errmsg} during follow-up merge window",
response.ret.unwrap_or(-1)
),
);
}
if let Some(next_cursor) = response.get_updates_buf.as_deref() {
if next_cursor != current_cursor {
*current_cursor = next_cursor.to_string();
if let Err(error) = persist_get_updates_buf(next_cursor) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist WeChat polling cursor: {error}"),
);
}
}
}
let mut saw_relevant_message = 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;
}
}
match incoming_bundle_from_message(config, message) {
Ok(Some(bundle)) => {
let emitted = process_incoming_bundle(
pending_inbound,
bundle,
pending_inbound_changed,
channel_host::now_millis(),
u64::from(config.inbound_merge_window_ms),
);
for emitted_bundle in emitted {
saw_relevant_message = true;
emit_buffered_bundle(emitted_bundle);
}
}
Ok(None) => {}
Err(error) => {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to map WeChat inbound message: {error}"),
);
}
}
}
if !saw_relevant_message && pending_inbound.is_empty() {
break;
}
}
}
fn next_flush_deadline(now_ms: u64, inbound_merge_window_ms: u64) -> u64 {
now_ms.saturating_add(inbound_merge_window_ms)
}
fn next_follow_up_timeout_ms(
pending_inbound: &std::collections::HashMap<String, PendingInboundBundle>,
now_ms: u64,
) -> Option<u64> {
pending_inbound
.values()
.map(|bundle| bundle.flush_at_ms.saturating_sub(now_ms))
.min()
}
fn take_due_pending_bundles(
pending_inbound: &mut std::collections::HashMap<String, PendingInboundBundle>,
now_ms: u64,
) -> Vec<PendingInboundBundle> {
let due_keys = pending_inbound
.iter()
.filter_map(|(key, bundle)| (bundle.flush_at_ms <= now_ms).then_some(key.clone()))
.collect::<Vec<_>>();
due_keys
.into_iter()
.filter_map(|key| pending_inbound.remove(&key))
.collect()
}
fn emit_buffered_bundle(bundle: PendingInboundBundle) {
let metadata = json!({
"from_user_id": bundle.from_user_id,
"to_user_id": bundle.to_user_id,
"message_id": bundle.message_id,
"session_id": bundle.session_id,
"context_token": bundle.context_token,
});
channel_host::emit_message(&EmittedMessage {
user_id: bundle.from_user_id.clone(),
user_name: None,
content: bundle.text,
thread_id: Some(format!("wechat:{}", bundle.from_user_id)),
metadata_json: metadata.to_string(),
attachments: bundle.attachments.into_iter().map(Into::into).collect(),
});
}
fn merge_bundle_metadata(target: &mut PendingInboundBundle, incoming: &PendingInboundBundle) {
if incoming.to_user_id.is_some() {
target.to_user_id = incoming.to_user_id.clone();
}
if incoming.session_id.is_some() {
target.session_id = incoming.session_id.clone();
}
if incoming.context_token.is_some() {
target.context_token = incoming.context_token.clone();
}
if incoming.message_id.is_some() {
target.message_id = incoming.message_id;
}
}
fn merge_text(existing: &str, incoming: &str) -> String {
let existing = existing.trim();
let incoming = incoming.trim();
match (existing.is_empty(), incoming.is_empty()) {
(true, true) => String::new(),
(true, false) => incoming.to_string(),
(false, true) => existing.to_string(),
(false, false) => format!("{existing}\n\n{incoming}"),
}
}
fn send_response(
config: &WechatConfig,
metadata: &OutboundMetadata,
response: &AgentResponse,
context_token: Option<&str>,
) -> Result<(), String> {
let mut remaining_text = response.content.trim().to_string();
let mut sent_attachment = false;
for attachment in &response.attachments {
if !attachment.mime_type.starts_with("image/") {
return Err(format!(
"WeChat currently supports image attachments only, got {} ({})",
attachment.filename, attachment.mime_type
));
}
let caption = if sent_attachment {
""
} else {
remaining_text.as_str()
};
media::send_image_attachment(
config,
&metadata.from_user_id,
attachment,
context_token,
caption,
)?;
sent_attachment = true;
remaining_text.clear();
}
if !remaining_text.is_empty() || !sent_attachment {
api::send_text_message(
config,
&metadata.from_user_id,
&remaining_text,
context_token,
)?;
}
Ok(())
}
fn extract_text(message: &WechatMessage) -> 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 if item.r#type == Some(crate::types::MESSAGE_ITEM_VOICE) {
item.voice_item
.as_ref()
.and_then(|item| item.text.as_ref())
.cloned()
} else {
None
}
})
.unwrap_or_default()
}
fn is_terminal_text_status(message: &str) -> bool {
let trimmed = message.trim();
trimmed.eq_ignore_ascii_case("done")
|| trimmed.eq_ignore_ascii_case("interrupted")
|| trimmed.eq_ignore_ascii_case("awaiting approval")
|| trimmed.eq_ignore_ascii_case("rejected")
}
fn classify_status_update(update: &StatusUpdate) -> Option<WechatStatusAction> {
match update.status {
StatusType::Thinking => Some(WechatStatusAction::Typing),
StatusType::Done
| StatusType::Interrupted
| StatusType::ApprovalNeeded
| StatusType::AuthRequired => Some(WechatStatusAction::Cancel),
StatusType::Status if is_terminal_text_status(&update.message) => {
Some(WechatStatusAction::Cancel)
}
StatusType::ToolStarted
| StatusType::ToolCompleted
| StatusType::ToolResult
| StatusType::Status
| StatusType::JobStarted
| StatusType::AuthCompleted => None,
}
}
fn resolve_context_token(
metadata: &OutboundMetadata,
context_tokens: &std::collections::HashMap<String, String>,
) -> Option<String> {
metadata
.context_token
.clone()
.or_else(|| context_tokens.get(&metadata.from_user_id).cloned())
}
fn cached_typing_ticket(user_id: &str) -> Option<String> {
let tickets = load_typing_tickets();
let ticket = tickets.get(user_id)?;
let trimmed = ticket.ticket.trim();
if trimmed.is_empty() {
return None;
}
let age_ms = channel_host::now_millis().saturating_sub(ticket.fetched_at_ms);
if age_ms >= TYPING_TICKET_TTL_MS {
return None;
}
Some(trimmed.to_string())
}
fn persist_typing_ticket(user_id: &str, ticket: &str) -> Result<(), String> {
let mut tickets = load_typing_tickets();
tickets.insert(
user_id.to_string(),
TypingTicketEntry {
ticket: ticket.to_string(),
fetched_at_ms: channel_host::now_millis(),
},
);
persist_typing_tickets(&tickets)
}
fn clear_typing_ticket(user_id: &str) -> Result<(), String> {
let mut tickets = load_typing_tickets();
if tickets.remove(user_id).is_some() {
persist_typing_tickets(&tickets)?;
}
Ok(())
}
fn resolve_typing_ticket(
config: &WechatConfig,
user_id: &str,
context_token: Option<&str>,
) -> Result<Option<String>, String> {
if let Some(ticket) = cached_typing_ticket(user_id) {
return Ok(Some(ticket));
}
let response = api::get_config(config, user_id, context_token)?;
if response.ret.unwrap_or(0) != 0 {
let errmsg = response
.errmsg
.as_deref()
.unwrap_or("unknown WeChat getConfig error");
return Err(format!(
"WeChat getConfig returned ret={} errmsg={errmsg}",
response.ret.unwrap_or(-1)
));
}
let Some(ticket) = response
.typing_ticket
.as_deref()
.map(str::trim)
.filter(|ticket| !ticket.is_empty())
else {
return Ok(None);
};
if let Err(error) = persist_typing_ticket(user_id, ticket) {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to persist WeChat typing ticket: {error}"),
);
}
Ok(Some(ticket.to_string()))
}
fn send_typing_indicator(
config: &WechatConfig,
metadata: &OutboundMetadata,
context_token: Option<&str>,
status: i32,
allow_ticket_fetch: bool,
) -> Result<(), String> {
let ticket = if allow_ticket_fetch {
resolve_typing_ticket(config, &metadata.from_user_id, context_token)?
} else {
cached_typing_ticket(&metadata.from_user_id)
};
let Some(ticket) = ticket else {
return Ok(());
};
if let Err(error) = api::send_typing(config, &metadata.from_user_id, &ticket, status) {
let _ = clear_typing_ticket(&metadata.from_user_id);
return Err(error);
}
Ok(())
}
export!(WechatChannel);
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{
classify_status_update, extract_text, merge_text, process_incoming_bundle,
take_due_pending_bundles, PendingInboundBundle, StoredInboundAttachment,
WechatStatusAction,
};
use crate::exports::near::agent::channel::{StatusType, StatusUpdate};
use crate::types::{MessageItem, VoiceItem, WechatMessage, MESSAGE_ITEM_VOICE};
fn make_bundle(user_id: &str, text: &str, image_count: usize) -> PendingInboundBundle {
PendingInboundBundle {
from_user_id: user_id.to_string(),
to_user_id: Some("bot".to_string()),
session_id: Some("session-1".to_string()),
context_token: Some("ctx-1".to_string()),
message_id: Some(1),
flush_at_ms: 0,
text: text.to_string(),
attachments: (0..image_count)
.map(|index| StoredInboundAttachment {
id: format!("att-{index}"),
mime_type: "image/jpeg".to_string(),
filename: Some(format!("photo-{index}.jpg")),
size_bytes: Some(128),
source_url: Some("https://example.com/image.jpg".to_string()),
storage_key: None,
extracted_text: None,
extras_json: "{}".to_string(),
})
.collect(),
}
}
#[test]
fn test_classify_status_update_thinking_starts_typing() {
let update = StatusUpdate {
status: StatusType::Thinking,
message: "Thinking...".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(WechatStatusAction::Typing)
);
}
#[test]
fn test_classify_status_update_done_cancels_typing() {
let update = StatusUpdate {
status: StatusType::Done,
message: "Done".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(WechatStatusAction::Cancel)
);
}
#[test]
fn test_classify_status_update_approval_needed_cancels_typing() {
let update = StatusUpdate {
status: StatusType::ApprovalNeeded,
message: "Approval needed".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(WechatStatusAction::Cancel)
);
}
#[test]
fn test_classify_status_update_tool_started_is_ignored() {
let update = StatusUpdate {
status: StatusType::ToolStarted,
message: "Tool started".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_classify_status_update_terminal_text_status_cancels_typing() {
let update = StatusUpdate {
status: StatusType::Status,
message: "Awaiting approval".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(
classify_status_update(&update),
Some(WechatStatusAction::Cancel)
);
}
#[test]
fn test_classify_status_update_progress_status_is_ignored() {
let update = StatusUpdate {
status: StatusType::Status,
message: "Context compaction started".to_string(),
metadata_json: "{}".to_string(),
};
assert_eq!(classify_status_update(&update), None);
}
#[test]
fn test_merge_text_joins_non_empty_segments() {
assert_eq!(merge_text("", "hello"), "hello");
assert_eq!(merge_text("look", "what is this"), "look\n\nwhat is this");
assert_eq!(merge_text("look", ""), "look");
}
#[test]
fn test_extract_text_uses_voice_transcript_when_present() {
let message = WechatMessage {
message_id: Some(1),
from_user_id: Some("user-1".to_string()),
to_user_id: Some("bot-1".to_string()),
session_id: None,
message_type: None,
context_token: None,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_VOICE),
text_item: None,
image_item: None,
voice_item: Some(VoiceItem {
media: None,
encode_type: Some(6),
playtime: Some(1500),
text: Some("voice transcript".to_string()),
}),
file_item: None,
}],
};
assert_eq!(extract_text(&message), "voice transcript");
}
#[test]
fn test_process_incoming_bundle_merges_buffered_image_with_follow_up_text() {
let mut pending = HashMap::new();
let mut changed = false;
let emitted = process_incoming_bundle(
&mut pending,
make_bundle("u1", "", 1),
&mut changed,
100,
5_000,
);
assert!(emitted.is_empty());
assert!(changed);
assert_eq!(pending.len(), 1);
assert_eq!(pending["u1"].flush_at_ms, 5100);
changed = false;
let emitted = process_incoming_bundle(
&mut pending,
make_bundle("u1", "What is in this image?", 0),
&mut changed,
200,
5_000,
);
assert!(changed);
assert!(pending.is_empty());
assert_eq!(emitted.len(), 1);
assert_eq!(emitted[0].text, "What is in this image?");
assert_eq!(emitted[0].attachments.len(), 1);
}
#[test]
fn test_process_incoming_bundle_emits_text_and_images_together_without_buffering() {
let mut pending = HashMap::new();
let mut changed = false;
let emitted = process_incoming_bundle(
&mut pending,
make_bundle("u1", "Look at this image", 1),
&mut changed,
100,
5_000,
);
assert!(!changed);
assert!(pending.is_empty());
assert_eq!(emitted.len(), 1);
assert_eq!(emitted[0].text, "Look at this image");
assert_eq!(emitted[0].attachments.len(), 1);
}
#[test]
fn test_take_due_pending_bundles_emits_only_expired_entries() {
let mut pending = HashMap::new();
let mut expired = make_bundle("u1", "", 1);
expired.flush_at_ms = 100;
let mut fresh = make_bundle("u2", "", 1);
fresh.flush_at_ms = 300;
pending.insert(expired.from_user_id.clone(), expired);
pending.insert(fresh.from_user_id.clone(), fresh);
let due = take_due_pending_bundles(&mut pending, 200);
assert_eq!(due.len(), 1);
assert_eq!(due[0].from_user_id, "u1");
assert_eq!(pending.len(), 1);
assert!(pending.contains_key("u2"));
}
}
-718
View File
@@ -1,718 +0,0 @@
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
use aes::Aes128;
use base64::Engine as _;
use md5::{Digest, Md5};
use rand::RngCore;
use serde_json::json;
use crate::exports::near::agent::channel::Attachment;
use crate::near::agent::channel_host::{self, InboundAttachment};
use crate::types::{
CdnMedia, FileItem, ImageItem, MessageItem, SendMessageRequest, WechatConfig,
MESSAGE_ITEM_FILE, MESSAGE_ITEM_IMAGE, MESSAGE_ITEM_VOICE, MESSAGE_STATE_FINISH,
MESSAGE_TYPE_BOT, UPLOAD_MEDIA_TYPE_IMAGE,
};
const AES_BLOCK_SIZE: usize = 16;
#[derive(Debug, Clone)]
pub struct UploadImage {
pub download_encrypted_query_param: String,
pub aes_key_base64: String,
pub file_size_ciphertext: u64,
}
pub fn extract_inbound_attachments(
config: &WechatConfig,
message: &crate::types::WechatMessage,
) -> Result<Vec<InboundAttachment>, String> {
message
.item_list
.iter()
.enumerate()
.filter_map(|(index, item)| {
map_inbound_attachment(config, message, item, index).transpose()
})
.collect()
}
pub fn send_image_attachment(
config: &WechatConfig,
to_user_id: &str,
attachment: &Attachment,
context_token: Option<&str>,
text: &str,
) -> Result<(), String> {
if attachment.data.is_empty() {
return Err(format!(
"WeChat image attachment '{}' has no data",
attachment.filename
));
}
let upload = upload_image(config, to_user_id, attachment)?;
if !text.trim().is_empty() {
crate::api::send_text_message(config, to_user_id, text.trim(), context_token)?;
}
let request = SendMessageRequest {
msg: crate::types::OutboundWechatMessage {
from_user_id: String::new(),
to_user_id: to_user_id.to_string(),
client_id: format!("wechat-{}", channel_host::now_millis()),
message_type: MESSAGE_TYPE_BOT,
message_state: MESSAGE_STATE_FINISH,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_IMAGE),
text_item: None,
image_item: Some(ImageItem {
media: Some(CdnMedia {
encrypt_query_param: Some(upload.download_encrypted_query_param.clone()),
aes_key: Some(upload.aes_key_base64.clone()),
encrypt_type: Some(1),
}),
aeskey: None,
mid_size: Some(upload.file_size_ciphertext),
}),
voice_item: None,
file_item: None,
}],
context_token: context_token.map(str::to_string),
},
base_info: crate::api::base_info(),
};
crate::api::send_message_request(config, &request)
}
fn map_inbound_attachment(
config: &WechatConfig,
message: &crate::types::WechatMessage,
item: &MessageItem,
index: usize,
) -> Result<Option<InboundAttachment>, String> {
if item.r#type == Some(MESSAGE_ITEM_IMAGE) {
return map_image_attachment(config, message, item, index);
}
if item.r#type == Some(MESSAGE_ITEM_VOICE) {
return map_voice_attachment(config, message, item, index);
}
if item.r#type == Some(MESSAGE_ITEM_FILE) {
return map_file_attachment(config, message, item, index);
}
Ok(None)
}
fn map_image_attachment(
config: &WechatConfig,
message: &crate::types::WechatMessage,
item: &MessageItem,
index: usize,
) -> Result<Option<InboundAttachment>, String> {
if item.r#type != Some(MESSAGE_ITEM_IMAGE) {
return Ok(None);
}
let image = item.image_item.as_ref().ok_or_else(|| {
format!(
"WeChat image message {:?} is missing image_item payload",
message.message_id
)
})?;
let media = image.media.as_ref().ok_or_else(|| {
format!(
"WeChat image message {:?} is missing media payload",
message.message_id
)
})?;
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
format!(
"WeChat image message {:?} is missing encrypt_query_param",
message.message_id
)
})?;
let message_id = message
.message_id
.ok_or_else(|| "WeChat image message is missing message_id".to_string())?;
let aes_key = preferred_image_aes_key(image, media).map(str::to_string);
Ok(Some(InboundAttachment {
id: format!("wechat-image-{}-{}", message_id, index),
mime_type: "image/jpeg".to_string(),
filename: Some(format!("wechat-image-{}-{}.jpg", message_id, index)),
size_bytes: image.mid_size,
source_url: Some(build_cdn_download_url(
&config.cdn_base_url,
encrypt_query_param,
)),
storage_key: None,
extracted_text: None,
extras_json: json!({ "wechat_aes_key": aes_key }).to_string(),
}))
}
fn map_file_attachment(
config: &WechatConfig,
message: &crate::types::WechatMessage,
item: &MessageItem,
index: usize,
) -> Result<Option<InboundAttachment>, String> {
if item.r#type != Some(MESSAGE_ITEM_FILE) {
return Ok(None);
}
let file = item.file_item.as_ref().ok_or_else(|| {
format!(
"WeChat file message {:?} is missing file_item payload",
message.message_id
)
})?;
let media = file.media.as_ref().ok_or_else(|| {
format!(
"WeChat file message {:?} is missing media payload",
message.message_id
)
})?;
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
format!(
"WeChat file message {:?} is missing encrypt_query_param",
message.message_id
)
})?;
let aes_key = media
.aes_key
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
format!(
"WeChat file message {:?} is missing aes_key",
message.message_id
)
})?;
let message_id = message
.message_id
.ok_or_else(|| "WeChat file message is missing message_id".to_string())?;
let filename = inbound_file_name(file, message_id, index);
let size_bytes = file.len.as_deref().and_then(parse_file_size);
Ok(Some(InboundAttachment {
id: format!("wechat-file-{}-{}", message_id, index),
mime_type: infer_file_mime_type(&filename),
filename: Some(filename),
size_bytes,
source_url: Some(build_cdn_download_url(
&config.cdn_base_url,
encrypt_query_param,
)),
storage_key: None,
extracted_text: None,
extras_json: json!({ "wechat_aes_key": aes_key }).to_string(),
}))
}
fn map_voice_attachment(
config: &WechatConfig,
message: &crate::types::WechatMessage,
item: &MessageItem,
index: usize,
) -> Result<Option<InboundAttachment>, String> {
if item.r#type != Some(MESSAGE_ITEM_VOICE) {
return Ok(None);
}
let voice = item.voice_item.as_ref().ok_or_else(|| {
format!(
"WeChat voice message {:?} is missing voice_item payload",
message.message_id
)
})?;
let media = voice.media.as_ref().ok_or_else(|| {
format!(
"WeChat voice message {:?} is missing media payload",
message.message_id
)
})?;
let encrypt_query_param = media.encrypt_query_param.as_deref().ok_or_else(|| {
format!(
"WeChat voice message {:?} is missing encrypt_query_param",
message.message_id
)
})?;
let aes_key = media
.aes_key
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
format!(
"WeChat voice message {:?} is missing aes_key",
message.message_id
)
})?;
let message_id = message
.message_id
.ok_or_else(|| "WeChat voice message is missing message_id".to_string())?;
let (mime_type, extension) = infer_voice_media_type(voice.encode_type);
let duration_secs = voice.playtime.map(|millis| (millis / 1000) as u32);
Ok(Some(InboundAttachment {
id: format!("wechat-voice-{}-{}", message_id, index),
mime_type: mime_type.to_string(),
filename: Some(format!(
"wechat-voice-{}-{}.{}",
message_id, index, extension
)),
size_bytes: None,
source_url: Some(build_cdn_download_url(
&config.cdn_base_url,
encrypt_query_param,
)),
storage_key: None,
extracted_text: voice
.text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
extras_json: build_voice_extras_json(aes_key, duration_secs),
}))
}
fn preferred_image_aes_key<'a>(image: &'a ImageItem, media: &'a CdnMedia) -> Option<&'a str> {
image
.aeskey
.as_deref()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
media
.aes_key
.as_deref()
.filter(|value| !value.trim().is_empty())
})
}
fn inbound_file_name(file: &FileItem, message_id: i64, index: usize) -> String {
file.file_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("wechat-file-{}-{}.bin", message_id, index))
}
fn parse_file_size(raw: &str) -> Option<u64> {
raw.trim().parse::<u64>().ok()
}
fn infer_voice_media_type(encode_type: Option<i32>) -> (&'static str, &'static str) {
match encode_type {
Some(7) => ("audio/mpeg", "mp3"),
Some(8) => ("audio/ogg", "ogg"),
Some(5) => ("audio/amr", "amr"),
Some(6) => ("audio/silk", "silk"),
_ => ("audio/silk", "silk"),
}
}
fn build_voice_extras_json(aes_key: &str, duration_secs: Option<u32>) -> String {
let mut extras = serde_json::Map::new();
extras.insert("wechat_aes_key".to_string(), json!(aes_key));
if let Some(duration_secs) = duration_secs {
extras.insert("duration_secs".to_string(), json!(duration_secs));
}
serde_json::Value::Object(extras).to_string()
}
fn infer_file_mime_type(filename: &str) -> String {
let extension = filename
.rsplit_once('.')
.map(|(_, ext)| ext.trim().to_ascii_lowercase());
match extension.as_deref() {
Some("pdf") => "application/pdf",
Some("doc") => "application/msword",
Some("docx") => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Some("xls") => "application/vnd.ms-excel",
Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Some("ppt") => "application/vnd.ms-powerpoint",
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
Some("txt") => "text/plain",
Some("csv") => "text/csv",
Some("json") => "application/json",
Some("xml") => "application/xml",
Some("md") => "text/markdown",
Some("zip") => "application/zip",
Some("tar") => "application/x-tar",
Some("gz") => "application/gzip",
Some("mp3") => "audio/mpeg",
Some("ogg") => "audio/ogg",
Some("wav") => "audio/wav",
Some("mp4") => "video/mp4",
Some("mov") => "video/quicktime",
Some("webm") => "video/webm",
Some("mkv") => "video/x-matroska",
Some("avi") => "video/x-msvideo",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
_ => "application/octet-stream",
}
.to_string()
}
fn upload_image(
config: &WechatConfig,
to_user_id: &str,
attachment: &Attachment,
) -> Result<UploadImage, String> {
let plaintext = &attachment.data;
let raw_size = plaintext.len() as u64;
let raw_md5 = hex_lower(md5_bytes(plaintext));
let file_size_ciphertext = padded_size(raw_size);
let filekey = hex_lower(random_bytes(16)?);
let aes_key = random_bytes(16)?;
let aes_key_hex = hex_lower(aes_key.clone());
let upload_url = crate::api::get_upload_url(
config,
&crate::types::GetUploadUrlRequest {
filekey: filekey.clone(),
media_type: UPLOAD_MEDIA_TYPE_IMAGE,
to_user_id: to_user_id.to_string(),
rawsize: raw_size,
rawfilemd5: raw_md5,
filesize: file_size_ciphertext,
no_need_thumb: true,
aeskey: aes_key_hex,
base_info: crate::api::base_info(),
},
)?;
let upload_param = upload_url
.upload_param
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "WeChat getUploadUrl returned no upload_param".to_string())?;
if upload_url.thumb_upload_param.is_some() {
channel_host::log(
channel_host::LogLevel::Debug,
"WeChat image upload returned thumb_upload_param; ignoring for single-image flow",
);
}
let ciphertext = encrypt_aes_ecb_pkcs7(plaintext, &aes_key)?;
let upload_response = channel_host::http_request(
"POST",
&build_cdn_upload_url(&config.cdn_base_url, upload_param, &filekey),
r#"{"Content-Type":"application/octet-stream"}"#,
Some(&ciphertext),
Some(15_000),
)
.map_err(|e| format!("WeChat CDN upload failed: {e}"))?;
if upload_response.status != 200 {
let body = String::from_utf8_lossy(&upload_response.body);
return Err(format!(
"WeChat CDN upload returned {}: {}",
upload_response.status, body
));
}
let headers: std::collections::HashMap<String, String> =
serde_json::from_str(&upload_response.headers_json)
.map_err(|e| format!("Failed to parse WeChat CDN upload headers: {e}"))?;
let download_encrypted_query_param = headers
.iter()
.find_map(|(key, value)| {
if key.eq_ignore_ascii_case("x-encrypted-param") {
Some(value.clone())
} else {
None
}
})
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "WeChat CDN upload response missing x-encrypted-param".to_string())?;
Ok(UploadImage {
download_encrypted_query_param,
aes_key_base64: base64::engine::general_purpose::STANDARD.encode(aes_key),
file_size_ciphertext,
})
}
fn build_cdn_download_url(cdn_base_url: &str, encrypted_query_param: &str) -> String {
format!(
"{}/download?encrypted_query_param={}",
cdn_base_url.trim_end_matches('/'),
percent_encode(encrypted_query_param)
)
}
fn build_cdn_upload_url(cdn_base_url: &str, upload_param: &str, filekey: &str) -> String {
format!(
"{}/upload?encrypted_query_param={}&filekey={}",
cdn_base_url.trim_end_matches('/'),
percent_encode(upload_param),
percent_encode(filekey)
)
}
fn percent_encode(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(byte as char);
} else {
encoded.push('%');
encoded.push(nibble_to_hex(byte >> 4));
encoded.push(nibble_to_hex(byte & 0x0F));
}
}
encoded
}
fn nibble_to_hex(nibble: u8) -> char {
match nibble {
0..=9 => (b'0' + nibble) as char,
10..=15 => (b'A' + (nibble - 10)) as char,
_ => '0',
}
}
fn encode_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(nibble_to_hex(byte >> 4));
out.push(nibble_to_hex(byte & 0x0F));
}
out
}
fn hex_lower(bytes: Vec<u8>) -> String {
encode_hex(&bytes).to_ascii_lowercase()
}
fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
let mut padded = plaintext.to_vec();
let pad_len = AES_BLOCK_SIZE - (padded.len() % AES_BLOCK_SIZE);
padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
for chunk in padded.chunks_exact_mut(AES_BLOCK_SIZE) {
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
}
Ok(padded)
}
fn md5_bytes(bytes: &[u8]) -> Vec<u8> {
Md5::digest(bytes).to_vec()
}
fn random_bytes(len: usize) -> Result<Vec<u8>, String> {
let mut bytes = vec![0u8; len];
rand::rngs::OsRng.fill_bytes(&mut bytes);
if bytes.iter().all(|byte| *byte == 0) {
return Err("OS RNG returned all-zero bytes unexpectedly".to_string());
}
Ok(bytes)
}
fn padded_size(raw_size: u64) -> u64 {
((raw_size / AES_BLOCK_SIZE as u64) + 1) * AES_BLOCK_SIZE as u64
}
#[cfg(test)]
mod tests {
use super::{
build_voice_extras_json, encode_hex, encrypt_aes_ecb_pkcs7, infer_file_mime_type,
infer_voice_media_type, map_file_attachment, map_image_attachment, map_voice_attachment,
AES_BLOCK_SIZE,
};
use crate::types::{
CdnMedia, FileItem, ImageItem, MessageItem, VoiceItem, WechatConfig, WechatMessage,
MESSAGE_ITEM_FILE, MESSAGE_ITEM_IMAGE, MESSAGE_ITEM_VOICE,
};
#[test]
fn test_encrypt_aes_ecb_pkcs7_is_block_aligned() {
let key = [0x11u8; 16];
let plaintext = b"wechat image payload".to_vec();
let ciphertext = encrypt_aes_ecb_pkcs7(&plaintext, &key).unwrap();
assert_eq!(ciphertext.len() % AES_BLOCK_SIZE, 0);
assert_ne!(ciphertext, plaintext);
assert_eq!(
encode_hex(&ciphertext).to_ascii_lowercase(),
"a7464c94a03fb2c5aa783597a1d2f5a461f1cd5d83a7bd92721e8ac1853f881f"
);
}
#[test]
fn test_map_image_attachment_errors_when_message_id_missing() {
let config = WechatConfig::default();
let message = WechatMessage {
message_id: None,
from_user_id: Some("user-1".to_string()),
to_user_id: Some("bot-1".to_string()),
session_id: None,
message_type: None,
context_token: None,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_IMAGE),
text_item: None,
image_item: Some(ImageItem {
media: Some(CdnMedia {
encrypt_query_param: Some("enc".to_string()),
aes_key: Some("aes".to_string()),
encrypt_type: Some(1),
}),
aeskey: None,
mid_size: Some(128),
}),
voice_item: None,
file_item: None,
}],
};
let error = map_image_attachment(&config, &message, &message.item_list[0], 0)
.expect_err("missing message_id should error");
assert!(error.contains("missing message_id"));
}
#[test]
fn test_map_file_attachment_uses_filename_and_size_metadata() {
let config = WechatConfig::default();
let message = WechatMessage {
message_id: Some(42),
from_user_id: Some("user-1".to_string()),
to_user_id: Some("bot-1".to_string()),
session_id: None,
message_type: None,
context_token: None,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_FILE),
text_item: None,
image_item: None,
voice_item: None,
file_item: Some(FileItem {
media: Some(CdnMedia {
encrypt_query_param: Some("enc".to_string()),
aes_key: Some("YWJjZGVmZ2hpamtsbW5vcA==".to_string()),
encrypt_type: Some(1),
}),
file_name: Some("report.PDF".to_string()),
len: Some("256".to_string()),
}),
}],
};
let attachment = map_file_attachment(&config, &message, &message.item_list[0], 0)
.expect("file attachment should map")
.expect("file attachment should be present");
assert_eq!(attachment.id, "wechat-file-42-0");
assert_eq!(attachment.mime_type, "application/pdf");
assert_eq!(attachment.filename.as_deref(), Some("report.PDF"));
assert_eq!(attachment.size_bytes, Some(256));
assert!(attachment.extras_json.contains("wechat_aes_key"));
}
#[test]
fn test_map_file_attachment_errors_when_message_id_missing() {
let config = WechatConfig::default();
let message = WechatMessage {
message_id: None,
from_user_id: Some("user-1".to_string()),
to_user_id: Some("bot-1".to_string()),
session_id: None,
message_type: None,
context_token: None,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_FILE),
text_item: None,
image_item: None,
voice_item: None,
file_item: Some(FileItem {
media: Some(CdnMedia {
encrypt_query_param: Some("enc".to_string()),
aes_key: Some("aes".to_string()),
encrypt_type: Some(1),
}),
file_name: Some("report.pdf".to_string()),
len: Some("256".to_string()),
}),
}],
};
let error = map_file_attachment(&config, &message, &message.item_list[0], 0)
.expect_err("missing message_id should error");
assert!(error.contains("missing message_id"));
}
#[test]
fn test_infer_file_mime_type_defaults_to_octet_stream() {
assert_eq!(
infer_file_mime_type("archive.unknown"),
"application/octet-stream"
);
assert_eq!(infer_file_mime_type("README"), "application/octet-stream");
}
#[test]
fn test_infer_voice_media_type_defaults_to_silk() {
assert_eq!(infer_voice_media_type(Some(6)), ("audio/silk", "silk"));
assert_eq!(infer_voice_media_type(Some(8)), ("audio/ogg", "ogg"));
assert_eq!(infer_voice_media_type(None), ("audio/silk", "silk"));
}
#[test]
fn test_build_voice_extras_json_includes_duration() {
let extras = build_voice_extras_json("aes-key", Some(9));
assert!(extras.contains("wechat_aes_key"));
assert!(extras.contains("duration_secs"));
}
#[test]
fn test_map_voice_attachment_sets_audio_metadata() {
let config = WechatConfig::default();
let message = WechatMessage {
message_id: Some(77),
from_user_id: Some("user-1".to_string()),
to_user_id: Some("bot-1".to_string()),
session_id: None,
message_type: None,
context_token: None,
item_list: vec![MessageItem {
r#type: Some(MESSAGE_ITEM_VOICE),
text_item: None,
image_item: None,
voice_item: Some(VoiceItem {
media: Some(CdnMedia {
encrypt_query_param: Some("enc".to_string()),
aes_key: Some("YWJjZGVmZ2hpamtsbW5vcA==".to_string()),
encrypt_type: Some(1),
}),
encode_type: Some(8),
playtime: Some(4200),
text: Some("hello from voice".to_string()),
}),
file_item: None,
}],
};
let attachment = map_voice_attachment(&config, &message, &message.item_list[0], 0)
.expect("voice attachment should map")
.expect("voice attachment should be present");
assert_eq!(attachment.id, "wechat-voice-77-0");
assert_eq!(attachment.mime_type, "audio/ogg");
assert_eq!(
attachment.filename.as_deref(),
Some("wechat-voice-77-0.ogg")
);
assert_eq!(
attachment.extracted_text.as_deref(),
Some("hello from voice")
);
assert!(attachment.extras_json.contains("duration_secs"));
}
}
-158
View File
@@ -1,158 +0,0 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::auth::{
CONFIG_PATH, CONTEXT_TOKENS_PATH, GET_UPDATES_BUF_PATH, PENDING_INBOUND_PATH,
TYPING_TICKETS_PATH,
};
use crate::near::agent::channel_host;
use crate::types::WechatConfig;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TypingTicketEntry {
pub ticket: String,
pub fetched_at_ms: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct StoredInboundAttachment {
pub id: String,
pub mime_type: String,
pub filename: Option<String>,
pub size_bytes: Option<u64>,
pub source_url: Option<String>,
pub storage_key: Option<String>,
pub extracted_text: Option<String>,
pub extras_json: String,
}
impl From<channel_host::InboundAttachment> for StoredInboundAttachment {
fn from(value: channel_host::InboundAttachment) -> Self {
Self {
id: value.id,
mime_type: value.mime_type,
filename: value.filename,
size_bytes: value.size_bytes,
source_url: value.source_url,
storage_key: value.storage_key,
extracted_text: value.extracted_text,
extras_json: value.extras_json,
}
}
}
impl From<StoredInboundAttachment> for channel_host::InboundAttachment {
fn from(value: StoredInboundAttachment) -> Self {
Self {
id: value.id,
mime_type: value.mime_type,
filename: value.filename,
size_bytes: value.size_bytes,
source_url: value.source_url,
storage_key: value.storage_key,
extracted_text: value.extracted_text,
extras_json: value.extras_json,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct PendingInboundBundle {
pub from_user_id: String,
pub to_user_id: Option<String>,
pub session_id: Option<String>,
pub context_token: Option<String>,
pub message_id: Option<i64>,
pub flush_at_ms: u64,
pub text: String,
pub attachments: Vec<StoredInboundAttachment>,
}
pub fn load_config() -> WechatConfig {
channel_host::workspace_read(CONFIG_PATH)
.and_then(|raw| serde_json::from_str::<WechatConfig>(&raw).ok())
.unwrap_or_default()
}
pub fn persist_config(config: &WechatConfig) -> 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 load_typing_tickets() -> HashMap<String, TypingTicketEntry> {
channel_host::workspace_read(TYPING_TICKETS_PATH)
.and_then(|raw| serde_json::from_str::<HashMap<String, TypingTicketEntry>>(&raw).ok())
.unwrap_or_default()
}
pub fn persist_typing_tickets(tickets: &HashMap<String, TypingTicketEntry>) -> Result<(), String> {
let serialized =
serde_json::to_string(tickets).map_err(|e| format!("Failed to serialize tickets: {e}"))?;
channel_host::workspace_write(TYPING_TICKETS_PATH, &serialized).map_err(|e| e.to_string())
}
pub fn load_pending_inbound_bundles() -> Result<HashMap<String, PendingInboundBundle>, String> {
parse_pending_inbound_bundles(channel_host::workspace_read(PENDING_INBOUND_PATH).as_deref())
}
pub fn persist_pending_inbound_bundles(
bundles: &HashMap<String, PendingInboundBundle>,
) -> Result<(), String> {
let serialized =
serde_json::to_string(bundles).map_err(|e| format!("Failed to serialize bundles: {e}"))?;
channel_host::workspace_write(PENDING_INBOUND_PATH, &serialized).map_err(|e| e.to_string())
}
fn parse_pending_inbound_bundles(
raw: Option<&str>,
) -> Result<HashMap<String, PendingInboundBundle>, String> {
match raw {
None => Ok(HashMap::new()),
Some(raw) => serde_json::from_str(raw)
.map_err(|e| format!("Failed to parse pending inbound bundles: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pending_inbound_bundles_missing_file_returns_empty_map() {
let bundles = parse_pending_inbound_bundles(None).expect("missing state should be empty");
assert!(bundles.is_empty());
}
#[test]
fn test_parse_pending_inbound_bundles_invalid_json_returns_error() {
let error =
parse_pending_inbound_bundles(Some("{not json")).expect_err("invalid json should err");
assert!(error.contains("Failed to parse pending inbound bundles"));
}
}
-216
View File
@@ -1,216 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WechatConfig {
#[serde(default = "default_base_url")]
pub base_url: String,
#[serde(default = "default_cdn_base_url")]
pub cdn_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,
#[serde(default = "default_inbound_merge_window_ms")]
pub inbound_merge_window_ms: u32,
}
fn default_base_url() -> String {
"https://ilinkai.weixin.qq.com".to_string()
}
fn default_cdn_base_url() -> String {
"https://novac2c.cdn.weixin.qq.com/c2c".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
}
fn default_inbound_merge_window_ms() -> u32 {
5_000
}
impl Default for WechatConfig {
fn default() -> Self {
Self {
base_url: default_base_url(),
cdn_base_url: default_cdn_base_url(),
bot_type: default_bot_type(),
poll_interval_ms: default_poll_interval_ms(),
long_poll_timeout_ms: default_long_poll_timeout_ms(),
inbound_merge_window_ms: default_inbound_merge_window_ms(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BaseInfo {
pub channel_version: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GetUploadUrlRequest {
pub filekey: String,
pub media_type: i32,
pub to_user_id: String,
pub rawsize: u64,
pub rawfilemd5: String,
pub filesize: u64,
pub no_need_thumb: bool,
pub aeskey: String,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GetUpdatesRequest {
pub get_updates_buf: String,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GetConfigRequest {
pub ilink_user_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_token: Option<String>,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetUpdatesResponse {
pub ret: Option<i32>,
pub errcode: Option<i32>,
pub errmsg: Option<String>,
#[serde(default)]
pub msgs: Vec<WechatMessage>,
pub get_updates_buf: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetUploadUrlResponse {
pub upload_param: Option<String>,
pub thumb_upload_param: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SendMessageRequest {
pub msg: OutboundWechatMessage,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SendTypingRequest {
pub ilink_user_id: String,
pub typing_ticket: String,
pub status: i32,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OutboundWechatMessage {
pub from_user_id: String,
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 WechatMessage {
pub message_id: Option<i64>,
pub from_user_id: Option<String>,
pub to_user_id: Option<String>,
pub session_id: Option<String>,
pub message_type: Option<i32>,
pub context_token: Option<String>,
#[serde(default)]
pub item_list: Vec<MessageItem>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GetConfigResponse {
pub ret: Option<i32>,
pub errmsg: Option<String>,
pub typing_ticket: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SendTypingResponse {
pub ret: Option<i32>,
pub errmsg: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MessageItem {
pub r#type: Option<i32>,
pub text_item: Option<TextItem>,
pub image_item: Option<ImageItem>,
pub voice_item: Option<VoiceItem>,
pub file_item: Option<FileItem>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct TextItem {
pub text: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CdnMedia {
pub encrypt_query_param: Option<String>,
pub aes_key: Option<String>,
pub encrypt_type: Option<i32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ImageItem {
pub media: Option<CdnMedia>,
pub aeskey: Option<String>,
pub mid_size: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct VoiceItem {
pub media: Option<CdnMedia>,
pub encode_type: Option<i32>,
pub playtime: Option<u64>,
pub text: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FileItem {
pub media: Option<CdnMedia>,
pub file_name: Option<String>,
pub len: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OutboundMetadata {
pub from_user_id: String,
pub to_user_id: Option<String>,
pub message_id: Option<i64>,
pub session_id: Option<String>,
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;
pub const MESSAGE_ITEM_IMAGE: i32 = 2;
pub const MESSAGE_ITEM_VOICE: i32 = 3;
pub const MESSAGE_ITEM_FILE: i32 = 4;
pub const TYPING_STATUS_TYPING: i32 = 1;
pub const TYPING_STATUS_CANCEL: i32 = 2;
pub const UPLOAD_MEDIA_TYPE_IMAGE: i32 = 1;
@@ -1,51 +0,0 @@
{
"version": "0.1.0",
"wit_version": "0.3.0",
"type": "channel",
"name": "wechat",
"description": "WeChat iLink Bot channel for direct-message chat via long polling",
"setup": {
"required_secrets": [
{
"name": "wechat_bot_token",
"prompt": "Connect this channel from the WeChat 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": "/" },
{ "host": "novac2c.cdn.weixin.qq.com", "path_prefix": "/c2c/" }
],
"rate_limit": {
"requests_per_minute": 60,
"requests_per_hour": 1200
}
},
"secrets": {
"allowed_names": ["wechat_*"]
},
"channel": {
"allowed_paths": [],
"allow_polling": true,
"min_poll_interval_ms": 30000,
"workspace_prefix": "channels/wechat/",
"callback_timeout_secs": 45,
"emit_rate_limit": {
"messages_per_minute": 100,
"messages_per_hour": 5000
}
}
},
"config": {
"base_url": "https://ilinkai.weixin.qq.com",
"cdn_base_url": "https://novac2c.cdn.weixin.qq.com/c2c",
"bot_type": "3",
"poll_interval_ms": 30000,
"long_poll_timeout_ms": 35000,
"inbound_merge_window_ms": 5000
}
}
+1 -1
View File
@@ -269,7 +269,7 @@ dependencies = [
[[package]]
name = "whatsapp-channel"
version = "0.2.0"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
@@ -1,262 +0,0 @@
# WeChat Integration Design
**Date:** 2026-03-25
**Status:** Ready for implementation
**Goal:** Add WeChat 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 WeChat 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 WeChat session
- supports multiple logged-in WeChat bot accounts
- treats WeChat 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/wechat/` 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/wechat/`
- 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/
wechat/
Cargo.toml
build.sh
wechat.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 WeChat 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 WeChat bot account
- direct-message text receive/send
- `getupdates` long-poll loop
- `sendmessage` outbound replies
- typing indicators via `getconfig` and `sendtyping`
- inbound image download/decrypt for vision
- outbound image upload/send via `getuploadurl`
- `context_token` persistence
- `get_updates_buf` persistence
- login persistence across restart
- extension-first packaging under `channels-src/wechat/`
### 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:
- broader media parity beyond the current image + inbound-file + inbound-voice path (outbound files and video)
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["WeChat 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/wechat/` should own:
- iLink API request/response types
- QR login protocol calls
- long-polling `getupdates`
- `context_token` storage and lookup
- outbound `sendmessage`
- WeChat-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
- `wechat_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 WeChat peer to its latest `context_token`.
### Inbound message mapping
For each inbound WeChat DM:
- `channel = "wechat"`
- `user_id = <wechat sender id>` or owner scope if it is the bound owner
- `thread_id = Some("wechat:<sender_id>")`
- `conversation_scope_id = Some("wechat:<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 WeChat-specific core logic.
---
## User Flow
Phase 1 should be **web-first**, because the target user is a normal WeChat user rather than a CLI-only operator.
1. Install or enable the `wechat` channel extension.
2. Click "Connect WeChat".
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 `wechat_bot_token`.
7. Channel reloads and starts polling.
8. User sends a DM in WeChat 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
- media upload/send
+2 -3
View File
@@ -15,14 +15,13 @@
},
"messaging": {
"display_name": "Messaging Channels",
"description": "Discord, Telegram, Slack, WhatsApp, and WeChat channels",
"description": "Discord, Telegram, Slack, and WhatsApp channels",
"extensions": [
"channels/discord",
"channels/telegram",
"channels/slack",
"channels/whatsapp",
"channels/feishu",
"channels/wechat"
"channels/feishu"
],
"shared_auth": null
},
-32
View File
@@ -1,32 +0,0 @@
{
"name": "wechat",
"display_name": "WeChat Channel",
"kind": "channel",
"version": "0.1.0",
"wit_version": "0.3.0",
"description": "Talk to your agent through a WeChat iLink bot account",
"keywords": [
"messaging",
"chat",
"wechat",
"wechat",
"qr"
],
"source": {
"dir": "channels-src/wechat",
"capabilities": "wechat.capabilities.json",
"crate_name": "wechat-channel"
},
"auth_summary": {
"method": "interactive",
"provider": "WeChat",
"secrets": [
"wechat_bot_token"
],
"shared_auth": null,
"setup_url": "https://ilinkai.weixin.qq.com"
},
"tags": [
"messaging"
]
}
+1
View File
@@ -303,6 +303,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
} else {
tool_defs
};
// Update context for this iteration
reason_ctx.available_tools = tool_defs;
// Preserve force_text if already set (e.g. by truncation escalation).
+60 -102
View File
@@ -31,58 +31,7 @@ fn requires_preexisting_uuid_thread(channel: &str) -> bool {
matches!(channel, "gateway" | "test")
}
fn validate_inbound_text_for_message(
safety: &crate::safety::SafetyLayer,
content: &str,
attachments: &[crate::channels::IncomingAttachment],
) -> crate::safety::ValidationResult {
if content.trim().is_empty() && !attachments.is_empty() {
crate::safety::ValidationResult::ok()
} else {
safety.validate_input(content)
}
}
impl Agent {
fn reject_unsafe_inbound_user_message(
&self,
message: &IncomingMessage,
content: &str,
) -> Option<SubmissionResult> {
let validation =
validate_inbound_text_for_message(self.safety(), content, &message.attachments);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Some(SubmissionResult::error(format!(
"Input rejected by safety validation: {details}",
)));
}
let violations = self.safety().check_policy(content);
if violations
.iter()
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
{
return Some(SubmissionResult::error("Input rejected by safety policy."));
}
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
"Inbound message blocked: contains leaked secret"
);
return Some(SubmissionResult::error(warning));
}
None
}
/// Hydrate a historical thread from DB into memory if not already present.
///
/// Called before `resolve_thread` so that the session manager finds the
@@ -278,11 +227,34 @@ impl Agent {
}
// Run the same safety checks that the normal path applies
// so blocked content is never stored in pending_messages.
if let Some(rejection) =
self.reject_unsafe_inbound_user_message(message, content)
// (validation, policy, secret scan) so that blocked content
// is never stored in pending_messages or serialized.
let validation = self.safety().validate_input(content);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Ok(SubmissionResult::error(format!(
"Input rejected by safety validation: {details}",
)));
}
let violations = self.safety().check_policy(content);
if violations
.iter()
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
{
return Ok(rejection);
return Ok(SubmissionResult::error("Input rejected by safety policy."));
}
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
"Queued message blocked: contains leaked secret"
);
return Ok(SubmissionResult::error(warning));
}
if !thread.queue_message(content.to_string()) {
@@ -336,11 +308,39 @@ impl Agent {
}
}
// Validate inbound content before the turn is created. Attachment-only
// messages are allowed to pass through so multimodal channels can send
// an empty text body alongside real image/document payloads.
if let Some(rejection) = self.reject_unsafe_inbound_user_message(message, content) {
return Ok(rejection);
// Safety validation for user input
let validation = self.safety().validate_input(content);
if !validation.is_valid {
let details = validation
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
return Ok(SubmissionResult::error(format!(
"Input rejected by safety validation: {}",
details
)));
}
let violations = self.safety().check_policy(content);
if violations
.iter()
.any(|rule| rule.action == crate::safety::PolicyAction::Block)
{
return Ok(SubmissionResult::error("Input rejected by safety policy."));
}
// Scan inbound messages for secrets (API keys, tokens).
// Catching them here prevents the LLM from echoing them back, which
// would trigger the outbound leak detector and create error loops.
if let Some(warning) = self.safety().scan_inbound_for_secrets(content) {
tracing::warn!(
user = %message.user_id,
channel = %message.channel,
"Inbound message blocked: contains leaked secret"
);
return Ok(SubmissionResult::error(warning));
}
// Handle explicit commands (starting with /) directly
@@ -1934,9 +1934,6 @@ fn rebuild_chat_messages_from_db(
#[cfg(test)]
mod tests {
use super::*;
use crate::channels::{AttachmentKind, IncomingAttachment};
use crate::config::SafetyConfig;
use crate::safety::SafetyLayer;
#[test]
fn test_rebuild_chat_messages_user_assistant_only() {
@@ -2074,45 +2071,6 @@ mod tests {
assert_eq!(result[7].content, "Written");
}
#[test]
fn test_validate_inbound_text_rejects_empty_text_without_attachments() {
let safety = SafetyLayer::new(&SafetyConfig {
max_output_length: 10_000,
injection_check_enabled: true,
});
let result = validate_inbound_text_for_message(&safety, "", &[]);
assert!(!result.is_valid);
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "input");
assert_eq!(result.errors[0].message, "Input cannot be empty");
}
#[test]
fn test_validate_inbound_text_allows_empty_text_when_attachments_exist() {
let safety = SafetyLayer::new(&SafetyConfig {
max_output_length: 10_000,
injection_check_enabled: true,
});
let attachments = vec![IncomingAttachment {
id: "image-1".to_string(),
kind: AttachmentKind::Image,
mime_type: "image/jpeg".to_string(),
filename: Some("photo.jpg".to_string()),
size_bytes: Some(128),
source_url: Some("https://example.com/photo.jpg".to_string()),
storage_key: None,
extracted_text: None,
data: vec![1, 2, 3],
duration_secs: None,
}];
let result = validate_inbound_text_for_message(&safety, "", &attachments);
assert!(result.is_valid);
assert!(result.errors.is_empty());
}
fn make_db_msg(role: &str, content: &str) -> crate::history::ConversationMessage {
crate::history::ConversationMessage {
id: uuid::Uuid::new_v4(),
+6 -61
View File
@@ -122,32 +122,18 @@ impl RelayClient {
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
/// for validating the callback — no URLs.
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
let url = format!("{}/oauth/slack/auth", self.base_url);
tracing::debug!(relay_url = %url, "RelayClient::initiate_oauth: sending request");
let mut query: Vec<(&str, &str)> = vec![];
if let Some(nonce) = state_nonce {
query.push(("state_nonce", nonce));
}
let resp = self
.http
.get(&url)
.get(format!("{}/oauth/slack/auth", self.base_url))
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.send()
.await
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::initiate_oauth: network request failed"
);
RelayError::Network(e.to_string())
})?;
tracing::debug!(
relay_url = %url,
status = %resp.status(),
"RelayClient::initiate_oauth: received response"
);
.map_err(|e| RelayError::Network(e.to_string()))?;
let status = resp.status();
if status.is_redirection() {
@@ -238,39 +224,20 @@ impl RelayClient {
method: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, RelayError> {
let url = format!("{}/proxy/{}/{}", self.base_url, provider, method);
tracing::debug!(
relay_url = %url,
provider = %provider,
method = %method,
"RelayClient::proxy_provider: sending request"
);
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
let resp = self
.http
.post(&url)
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
.bearer_auth(self.api_key.expose_secret())
.query(&query)
.json(&body)
.send()
.await
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::proxy_provider: network request failed"
);
RelayError::Network(e.to_string())
})?;
.map_err(|e| RelayError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
tracing::warn!(
relay_url = %url,
status = status,
"RelayClient::proxy_provider: channel-relay returned error"
);
return Err(RelayError::Api {
status,
message: body,
@@ -288,45 +255,23 @@ impl RelayClient {
/// 32-byte secret. Called once at activation time; the result is cached in the
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
let url = format!("{}/relay/signing-secret", self.base_url);
tracing::debug!(
relay_url = %url,
"RelayClient::get_signing_secret: fetching signing secret"
);
let resp = self
.http
.get(&url)
.get(format!("{}/relay/signing-secret", self.base_url))
.bearer_auth(self.api_key.expose_secret())
.query(&[("team_id", team_id)])
.send()
.await
.map_err(|e| {
tracing::warn!(
relay_url = %url,
error = %e,
"RelayClient::get_signing_secret: network request failed"
);
RelayError::Network(e.to_string())
})?;
.map_err(|e| RelayError::Network(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
tracing::warn!(
relay_url = %url,
status = status,
body = %body,
"RelayClient::get_signing_secret: channel-relay returned error"
);
return Err(RelayError::Api {
status,
message: body,
});
}
tracing::debug!(
relay_url = %url,
"RelayClient::get_signing_secret: received successful response"
);
let body: serde_json::Value = resp
.json()
-435
View File
@@ -1,435 +0,0 @@
use std::time::Duration;
use aes::Aes128;
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
use base64::Engine as _;
use serde::Deserialize;
use silk_rs::decode_silk;
use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::host::{Attachment, ChannelHostState};
const AES_BLOCK_SIZE: usize = 16;
const MAX_ATTACHMENT_BYTES: usize = 20 * 1024 * 1024;
const WECHAT_CHANNEL_NAME: &str = "wechat";
const WECHAT_SILK_SAMPLE_RATE_HZ: i32 = 24_000;
#[derive(Debug, Deserialize)]
struct WechatAttachmentExtras {
wechat_aes_key: Option<String>,
}
pub(crate) async fn hydrate_attachment_for_channel(
channel_name: &str,
capabilities: &ChannelCapabilities,
attachment: &mut Attachment,
) {
if !should_hydrate_wechat_attachment(channel_name, attachment) {
return;
}
let Some(source_url) = attachment.source_url.as_deref() else {
return;
};
let Some(encoded_aes_key) = wechat_aes_key(&attachment.extras_json) else {
tracing::warn!(
channel = %channel_name,
attachment_id = %attachment.id,
"Skipping WeChat attachment hydration: missing AES key metadata"
);
return;
};
match download_wechat_attachment_bytes(channel_name, capabilities, source_url).await {
Ok(ciphertext) => match decrypt_wechat_attachment_bytes(&ciphertext, &encoded_aes_key) {
Ok(plaintext) => {
attachment.size_bytes = Some(plaintext.len() as u64);
attachment.data = plaintext;
if attachment.mime_type.starts_with("image/") {
attachment.mime_type = detect_image_mime(&attachment.data).to_string();
} else if is_wechat_silk_attachment(attachment) {
if let Err(error) = maybe_transcode_wechat_silk_attachment(attachment) {
tracing::warn!(
channel = %channel_name,
attachment_id = %attachment.id,
error = %error,
"Failed to transcode WeChat SILK attachment; preserving raw SILK"
);
}
}
}
Err(error) => {
tracing::warn!(
channel = %channel_name,
attachment_id = %attachment.id,
error = %error,
"Failed to decrypt WeChat attachment"
);
}
},
Err(error) => {
tracing::warn!(
channel = %channel_name,
attachment_id = %attachment.id,
error = %error,
"Failed to download WeChat attachment"
);
}
}
}
fn is_wechat_silk_attachment(attachment: &Attachment) -> bool {
attachment.mime_type.eq_ignore_ascii_case("audio/silk")
|| attachment
.filename
.as_deref()
.and_then(|filename| filename.rsplit_once('.').map(|(_, ext)| ext))
.is_some_and(|ext| ext.eq_ignore_ascii_case("silk"))
}
fn should_hydrate_wechat_attachment(channel_name: &str, attachment: &Attachment) -> bool {
channel_name == WECHAT_CHANNEL_NAME
&& attachment.data.is_empty()
&& attachment.source_url.is_some()
}
fn wechat_aes_key(extras_json: &str) -> Option<String> {
if extras_json.trim().is_empty() {
return None;
}
serde_json::from_str::<WechatAttachmentExtras>(extras_json)
.ok()
.and_then(|extras| extras.wechat_aes_key)
.filter(|value| !value.trim().is_empty())
}
async fn download_wechat_attachment_bytes(
channel_name: &str,
capabilities: &ChannelCapabilities,
source_url: &str,
) -> Result<Vec<u8>, String> {
let host_state = ChannelHostState::new(channel_name, capabilities.clone());
host_state.check_http_allowed(source_url, "GET")?;
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let response = client
.get(source_url)
.timeout(Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("WeChat CDN download failed: {e}"))?;
if response.status() != reqwest::StatusCode::OK {
return Err(format!(
"WeChat CDN download returned {}",
response.status()
));
}
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read WeChat CDN response body: {e}"))?
.to_vec();
if bytes.is_empty() {
return Err("WeChat CDN download returned an empty body".to_string());
}
if bytes.len() > MAX_ATTACHMENT_BYTES {
return Err(format!(
"WeChat attachment exceeds {MAX_ATTACHMENT_BYTES} bytes"
));
}
Ok(bytes)
}
fn decrypt_wechat_attachment_bytes(
ciphertext: &[u8],
encoded_aes_key: &str,
) -> Result<Vec<u8>, String> {
let key = parse_aes_key(encoded_aes_key)?;
decrypt_aes_ecb_pkcs7(ciphertext, &key)
}
fn parse_aes_key(encoded: &str) -> Result<Vec<u8>, String> {
let decoded = if encoded.len() == 32 && encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) {
decode_hex(encoded)?
} else {
base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| format!("Failed to decode WeChat AES key: {e}"))?
};
if decoded.len() == AES_BLOCK_SIZE {
return Ok(decoded);
}
if decoded.len() == 32 && decoded.iter().all(|byte| byte.is_ascii_hexdigit()) {
return decode_hex(
std::str::from_utf8(&decoded)
.map_err(|e| format!("WeChat AES key hex payload is not valid UTF-8: {e}"))?,
);
}
Err(format!(
"WeChat AES key must decode to 16 bytes or a 32-char hex string, got {} bytes",
decoded.len()
))
}
fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
if !input.len().is_multiple_of(2) {
return Err("hex input length must be even".to_string());
}
let mut bytes = Vec::with_capacity(input.len() / 2);
let chars: Vec<u8> = input.as_bytes().to_vec();
for idx in (0..chars.len()).step_by(2) {
let high = from_hex_digit(chars[idx])?;
let low = from_hex_digit(chars[idx + 1])?;
bytes.push((high << 4) | low);
}
Ok(bytes)
}
fn from_hex_digit(value: u8) -> Result<u8, String> {
match value {
b'0'..=b'9' => Ok(value - b'0'),
b'a'..=b'f' => Ok(value - b'a' + 10),
b'A'..=b'F' => Ok(value - b'A' + 10),
_ => Err(format!("invalid hex digit '{}'", value as char)),
}
}
fn decrypt_aes_ecb_pkcs7(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
if !ciphertext.len().is_multiple_of(AES_BLOCK_SIZE) {
return Err("ciphertext length is not a multiple of 16 bytes".to_string());
}
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
let mut plaintext = ciphertext.to_vec();
for chunk in plaintext.chunks_exact_mut(AES_BLOCK_SIZE) {
cipher.decrypt_block(GenericArray::from_mut_slice(chunk));
}
let pad_len = *plaintext
.last()
.ok_or_else(|| "ciphertext decrypted to an empty buffer".to_string())?
as usize;
if pad_len == 0 || pad_len > AES_BLOCK_SIZE || pad_len > plaintext.len() {
return Err("invalid PKCS7 padding".to_string());
}
if !plaintext[plaintext.len() - pad_len..]
.iter()
.all(|byte| *byte as usize == pad_len)
{
return Err("invalid PKCS7 padding bytes".to_string());
}
plaintext.truncate(plaintext.len() - pad_len);
Ok(plaintext)
}
fn detect_image_mime(bytes: &[u8]) -> &'static str {
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
"image/png"
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
"image/jpeg"
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
"image/gif"
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
"image/webp"
} else {
"image/jpeg"
}
}
fn maybe_transcode_wechat_silk_attachment(attachment: &mut Attachment) -> Result<(), String> {
if attachment.data.is_empty() {
return Err("SILK attachment has no data".to_string());
}
let pcm = decode_silk(&attachment.data, WECHAT_SILK_SAMPLE_RATE_HZ)
.map_err(|error| format!("SILK decode failed: {error}"))?;
if pcm.is_empty() {
return Err("SILK decoder returned empty PCM".to_string());
}
let wav = pcm_s16le_to_wav(&pcm, WECHAT_SILK_SAMPLE_RATE_HZ as u32)?;
attachment.data = wav;
attachment.size_bytes = Some(attachment.data.len() as u64);
attachment.mime_type = "audio/wav".to_string();
if let Some(filename) = attachment.filename.as_mut() {
replace_attachment_extension(filename, "wav");
}
Ok(())
}
fn pcm_s16le_to_wav(pcm: &[u8], sample_rate_hz: u32) -> Result<Vec<u8>, String> {
if !pcm.len().is_multiple_of(2) {
return Err("PCM buffer length must be even for 16-bit mono audio".to_string());
}
let data_len = u32::try_from(pcm.len())
.map_err(|_| "PCM buffer exceeds WAV container size limits".to_string())?;
let total_len = 44u32
.checked_add(data_len)
.ok_or_else(|| "WAV container size overflowed".to_string())?;
let byte_rate = sample_rate_hz
.checked_mul(2)
.ok_or_else(|| "WAV byte rate overflowed".to_string())?;
let mut wav = Vec::with_capacity(total_len as usize);
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(total_len - 8).to_le_bytes());
wav.extend_from_slice(b"WAVE");
wav.extend_from_slice(b"fmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&sample_rate_hz.to_le_bytes());
wav.extend_from_slice(&byte_rate.to_le_bytes());
wav.extend_from_slice(&2u16.to_le_bytes());
wav.extend_from_slice(&16u16.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&data_len.to_le_bytes());
wav.extend_from_slice(pcm);
Ok(wav)
}
fn replace_attachment_extension(filename: &mut String, replacement: &str) {
if let Some((stem, _)) = filename.rsplit_once('.') {
*filename = format!("{stem}.{replacement}");
} else {
filename.push('.');
filename.push_str(replacement);
}
}
#[cfg(test)]
fn encrypt_aes_ecb_pkcs7(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, String> {
use aes::cipher::BlockEncrypt;
let cipher = Aes128::new_from_slice(key).map_err(|e| format!("Invalid AES key: {e}"))?;
let mut padded = plaintext.to_vec();
let pad_len = AES_BLOCK_SIZE - (padded.len() % AES_BLOCK_SIZE);
padded.extend(std::iter::repeat_n(pad_len as u8, pad_len));
for chunk in padded.chunks_exact_mut(AES_BLOCK_SIZE) {
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
}
Ok(padded)
}
#[cfg(test)]
mod tests {
use super::{
Attachment, decrypt_wechat_attachment_bytes, detect_image_mime, encrypt_aes_ecb_pkcs7,
hydrate_attachment_for_channel, maybe_transcode_wechat_silk_attachment, pcm_s16le_to_wav,
should_hydrate_wechat_attachment,
};
use crate::channels::wasm::ChannelCapabilities;
use base64::Engine as _;
fn make_attachment() -> Attachment {
Attachment {
id: "wechat-image-1".to_string(),
mime_type: "image/jpeg".to_string(),
filename: Some("wechat-image.jpg".to_string()),
size_bytes: None,
source_url: Some(
"https://novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=test"
.to_string(),
),
storage_key: None,
extracted_text: None,
extras_json: String::new(),
data: Vec::new(),
duration_secs: None,
}
}
fn encode_test_extras_json(aes_key: &str) -> String {
serde_json::json!({ "wechat_aes_key": aes_key }).to_string()
}
#[test]
fn decrypt_wechat_image_bytes_round_trips() {
let key = [7u8; 16];
let plaintext = vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x11];
let ciphertext = encrypt_aes_ecb_pkcs7(&plaintext, &key).unwrap();
let encoded_key = base64::engine::general_purpose::STANDARD.encode(key);
let decrypted = decrypt_wechat_attachment_bytes(&ciphertext, &encoded_key).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn detect_image_mime_prefers_magic_bytes() {
assert_eq!(detect_image_mime(&[0xFF, 0xD8, 0xFF, 0x00]), "image/jpeg");
assert_eq!(
detect_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]),
"image/png"
);
}
#[test]
fn wechat_attachment_hydration_applies_to_wechat_encrypted_media() {
let mut attachment = make_attachment();
attachment.extras_json = encode_test_extras_json("ZmFrZS1rZXk=");
assert!(should_hydrate_wechat_attachment("wechat", &attachment));
assert!(!should_hydrate_wechat_attachment("telegram", &attachment));
attachment.mime_type = "application/pdf".to_string();
assert!(should_hydrate_wechat_attachment("wechat", &attachment));
}
#[tokio::test]
async fn hydration_skips_when_metadata_is_missing() {
let mut attachment = make_attachment();
let caps = ChannelCapabilities::for_channel("wechat");
hydrate_attachment_for_channel("wechat", &caps, &mut attachment).await;
assert!(attachment.data.is_empty());
assert_eq!(attachment.size_bytes, None);
}
#[test]
fn pcm_s16le_to_wav_wraps_pcm_with_expected_header() {
let wav = pcm_s16le_to_wav(&[0x00, 0x00, 0x01, 0x00], 24_000).expect("wav wrapping");
assert!(wav.starts_with(b"RIFF"));
assert_eq!(&wav[8..12], b"WAVE");
assert_eq!(&wav[12..16], b"fmt ");
assert_eq!(&wav[36..40], b"data");
assert_eq!(&wav[40..44], &(4u32).to_le_bytes());
assert_eq!(&wav[44..], &[0x00, 0x00, 0x01, 0x00]);
}
#[test]
fn silk_transcode_failure_preserves_raw_silk_path_for_callers() {
let mut attachment = Attachment {
id: "wechat-voice-1".to_string(),
mime_type: "audio/silk".to_string(),
filename: Some("wechat-voice-1.silk".to_string()),
size_bytes: Some(3),
source_url: None,
storage_key: None,
extracted_text: None,
extras_json: encode_test_extras_json("ZmFrZS1rZXk="),
data: vec![1, 2, 3],
duration_secs: Some(1),
};
let original = attachment.data.clone();
let error =
maybe_transcode_wechat_silk_attachment(&mut attachment).expect_err("invalid SILK");
assert!(error.contains("SILK decode failed"));
assert_eq!(attachment.mime_type, "audio/silk");
assert_eq!(attachment.filename.as_deref(), Some("wechat-voice-1.silk"));
assert_eq!(attachment.data, original);
}
}
-3
View File
@@ -35,8 +35,6 @@ pub struct Attachment {
pub storage_key: Option<String>,
/// Extracted text content (e.g., OCR result, PDF text, audio transcript).
pub extracted_text: Option<String>,
/// Extensible metadata from the channel payload.
pub extras_json: String,
/// Raw file bytes (for small files downloaded by the channel).
pub data: Vec<u8>,
/// Duration in seconds (for audio/video).
@@ -997,7 +995,6 @@ mod tests {
source_url: None,
storage_key: None,
extracted_text: None,
extras_json: String::new(),
data: Vec::new(),
duration_secs: None,
}
-1
View File
@@ -78,7 +78,6 @@
//! }
//! ```
mod attachment_hydration;
mod bundled;
mod capabilities;
mod error;
-9
View File
@@ -159,11 +159,6 @@ impl WasmChannelRouter {
self.channels.read().await.get(channel_name).cloned()
}
/// Get a registered channel directly by name.
pub async fn get_channel_by_name(&self, channel_name: &str) -> Option<Arc<WasmChannel>> {
self.channels.read().await.get(channel_name).cloned()
}
/// Validate a secret for a channel.
pub async fn validate_secret(&self, channel_name: &str, provided: &str) -> bool {
let secrets = self.secrets.read().await;
@@ -715,10 +710,6 @@ mod tests {
// Should not find non-existent path
let not_found = router.get_channel_for_path("/webhook/telegram").await;
assert!(not_found.is_none());
let found_by_name = router.get_channel_by_name("slack").await;
assert!(found_by_name.is_some());
assert_eq!(found_by_name.unwrap().channel_name(), "slack");
}
#[tokio::test]
-35
View File
@@ -139,13 +139,6 @@ impl ChannelCapabilitiesFile {
serde_json::to_string(&self.config).unwrap_or_else(|_| "{}".to_string())
}
/// Whether this channel declares owner/pairing gating in its config.
pub fn requires_binding(&self) -> bool {
["owner_id", "dm_policy", "allow_from"]
.iter()
.any(|key| self.config.contains_key(*key))
}
/// Get the webhook secret header name for this channel.
///
/// Returns the configured header name from capabilities, or a sensible default.
@@ -576,34 +569,6 @@ mod tests {
assert_eq!(caps.workspace_prefix, "integrations/custom/");
}
#[test]
fn test_requires_binding_detects_dm_owner_fields() {
let telegram = ChannelCapabilitiesFile::from_json(
r#"{
"name": "telegram",
"config": {
"owner_id": null,
"dm_policy": "pairing",
"allow_from": []
}
}"#,
)
.unwrap();
assert!(telegram.requires_binding());
let wechat = ChannelCapabilitiesFile::from_json(
r#"{
"name": "wechat",
"config": {
"base_url": "https://ilinkai.weixin.qq.com",
"bot_type": "3"
}
}"#,
)
.unwrap();
assert!(!wechat.requires_binding());
}
#[test]
fn test_emit_rate_limit() {
let json = r#"{
+2 -107
View File
@@ -190,20 +190,7 @@ 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,
&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;
inject_channel_secrets_into_config(&channel_name, secrets_store, &mut config_updates).await;
if !config_updates.is_empty() {
channel_arc.update_config(config_updates).await;
@@ -409,7 +396,6 @@ 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>,
) {
@@ -427,7 +413,7 @@ async fn inject_channel_secrets_into_config(
};
for &(config_key, secret_name) in secret_config_mappings {
match secrets.get_decrypted(owner_id, secret_name).await {
match secrets.get_decrypted("default", secret_name).await {
Ok(decrypted) => {
config_updates.insert(
config_key.to_string(),
@@ -456,94 +442,3 @@ 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 {
"wechat" => &[("base_url", "extensions.wechat.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("wechat-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.wechat.base_url",
&serde_json::json!("https://default.example"),
)
.await
.map_err(|e| format!("persist default setting failed: {e}"))?;
db.set_setting(
"owner-123",
"extensions.wechat.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(
"wechat",
"owner-123",
Some(&settings_store),
&mut config_updates,
)
.await;
assert_eq!(
config_updates.get("base_url"),
Some(&serde_json::json!("https://owner.example"))
);
Ok(())
}
}
+78 -145
View File
@@ -573,7 +573,6 @@ impl near::agent::channel_host::Host for ChannelStoreData {
source_url: a.source_url,
storage_key: a.storage_key,
extracted_text: a.extracted_text,
extras_json: a.extras_json,
data,
duration_secs,
}
@@ -1182,32 +1181,22 @@ impl WasmChannel {
)
}
fn log_host_state_entries(channel_name: &str, host_state: &mut ChannelHostState) {
fn log_on_start_host_state(&self, host_state: &mut ChannelHostState) {
for entry in host_state.take_logs() {
match entry.level {
crate::tools::wasm::LogLevel::Trace => {
tracing::trace!(channel = %channel_name, "{}", entry.message);
}
crate::tools::wasm::LogLevel::Debug => {
tracing::debug!(channel = %channel_name, "{}", entry.message);
}
crate::tools::wasm::LogLevel::Info => {
tracing::info!(channel = %channel_name, "{}", entry.message);
}
crate::tools::wasm::LogLevel::Error => {
tracing::error!(channel = %channel_name, "{}", entry.message);
tracing::error!(channel = %self.name, "{}", entry.message);
}
crate::tools::wasm::LogLevel::Warn => {
tracing::warn!(channel = %channel_name, "{}", entry.message);
tracing::warn!(channel = %self.name, "{}", entry.message);
}
_ => {
tracing::debug!(channel = %self.name, "{}", entry.message);
}
}
}
}
fn log_on_start_host_state(&self, host_state: &mut ChannelHostState) {
Self::log_host_state_entries(&self.name, host_state);
}
async fn execute_on_start_with_state(
&self,
) -> Result<(Result<ChannelConfig, WasmChannelError>, ChannelHostState), WasmChannelError> {
@@ -1491,20 +1480,18 @@ impl WasmChannel {
// Call on_poll using the generated typed interface
let channel_iface = instance.near_agent_channel();
let poll_result = channel_iface
channel_iface
.call_on_poll(&mut store)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel));
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
let mut host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
if poll_result.is_ok() {
// Commit pending workspace writes only after a successful callback.
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
}
// Commit pending workspace writes to the persistent store
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
Ok((poll_result, host_state))
Ok(((), host_state))
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
@@ -1516,10 +1503,7 @@ impl WasmChannel {
let channel_name = self.name.clone();
match result {
Ok(Ok((poll_result, mut host_state))) => {
Self::log_host_state_entries(&channel_name, &mut host_state);
poll_result?;
Ok(Ok(((), mut host_state))) => {
// Process emitted messages
let emitted = host_state.take_emitted_messages();
self.process_emitted_messages(emitted).await?;
@@ -2197,16 +2181,6 @@ impl WasmChannel {
};
for emitted in messages {
let EmittedMessage {
user_id,
user_name,
content,
thread_id,
metadata_json,
attachments,
..
} = emitted;
// Check rate limit — acquire and release the write lock before send().await
{
let mut rate_limiter = self.rate_limiter.write().await;
@@ -2224,41 +2198,55 @@ impl WasmChannel {
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
&self.owner_scope_id,
self.owner_actor_id.as_deref(),
&user_id,
&emitted.user_id,
);
// Convert to IncomingMessage
let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &content)
let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &emitted.content)
.with_owner_id(&self.owner_scope_id)
.with_sender_id(&user_id);
.with_sender_id(&emitted.user_id);
if let Some(name) = user_name {
if let Some(name) = emitted.user_name {
msg = msg.with_user_name(name);
}
if let Some(thread_id) = thread_id {
if let Some(thread_id) = emitted.thread_id {
msg = msg.with_thread(thread_id);
}
// Convert attachments
if !attachments.is_empty() {
let incoming_attachments =
convert_emitted_attachments(&self.name, &self.capabilities, attachments).await;
if !emitted.attachments.is_empty() {
let incoming_attachments = emitted
.attachments
.iter()
.map(|a| crate::channels::IncomingAttachment {
id: a.id.clone(),
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
mime_type: a.mime_type.clone(),
filename: a.filename.clone(),
size_bytes: a.size_bytes,
source_url: a.source_url.clone(),
storage_key: a.storage_key.clone(),
extracted_text: a.extracted_text.clone(),
data: a.data.clone(),
duration_secs: a.duration_secs,
})
.collect();
msg = msg.with_attachments(incoming_attachments);
}
// Parse metadata JSON
msg = apply_emitted_metadata(msg, &metadata_json);
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
if is_owner_sender {
// Store for owner-target routing (chat_id etc.).
self.update_broadcast_metadata(&metadata_json).await;
self.update_broadcast_metadata(&emitted.metadata_json).await;
}
// Send to stream — no locks held across this await
tracing::info!(
channel = %self.name,
user_id = %user_id,
content_len = content.len(),
user_id = %emitted.user_id,
content_len = emitted.content.len(),
attachment_count = msg.attachments.len(),
"Sending emitted message to agent"
);
@@ -2343,7 +2331,6 @@ impl WasmChannel {
&& let Err(e) = Self::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: &channel_name,
capabilities: &capabilities,
owner_scope_id: &owner_scope_id,
owner_actor_id: owner_actor_id.as_deref(),
message_tx: &message_tx,
@@ -2429,20 +2416,18 @@ impl WasmChannel {
// Call on_poll using the generated typed interface
let channel_iface = instance.near_agent_channel();
let poll_result = channel_iface
channel_iface
.call_on_poll(&mut store)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel));
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
let mut host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
if poll_result.is_ok() {
// Commit pending workspace writes only after a successful callback.
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
}
// Commit pending workspace writes to the persistent store
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
Ok((poll_result, host_state))
Ok(host_state)
})
.await
.map_err(|e| WasmChannelError::ExecutionPanicked {
@@ -2453,10 +2438,7 @@ impl WasmChannel {
.await;
match result {
Ok(Ok((poll_result, mut host_state))) => {
Self::log_host_state_entries(channel_name, &mut host_state);
poll_result?;
Ok(Ok(mut host_state)) => {
let emitted = host_state.take_emitted_messages();
tracing::debug!(
channel = %channel_name,
@@ -2502,16 +2484,6 @@ impl WasmChannel {
};
for emitted in messages {
let EmittedMessage {
user_id,
user_name,
content,
thread_id,
metadata_json,
attachments,
..
} = emitted;
// Check rate limit — acquire and release the write lock before send().await
{
let mut limiter = dispatch.rate_limiter.write().await;
@@ -2526,40 +2498,54 @@ impl WasmChannel {
}
}
let (resolved_user_id, is_owner_sender) =
resolve_message_scope(dispatch.owner_scope_id, dispatch.owner_actor_id, &user_id);
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
dispatch.owner_scope_id,
dispatch.owner_actor_id,
&emitted.user_id,
);
// Convert to IncomingMessage
let mut msg = IncomingMessage::new(dispatch.channel_name, &resolved_user_id, &content)
.with_owner_id(dispatch.owner_scope_id)
.with_sender_id(&user_id);
let mut msg =
IncomingMessage::new(dispatch.channel_name, &resolved_user_id, &emitted.content)
.with_owner_id(dispatch.owner_scope_id)
.with_sender_id(&emitted.user_id);
if let Some(name) = user_name {
if let Some(name) = emitted.user_name {
msg = msg.with_user_name(name);
}
if let Some(thread_id) = thread_id {
if let Some(thread_id) = emitted.thread_id {
msg = msg.with_thread(thread_id);
}
// Convert attachments
if !attachments.is_empty() {
let incoming_attachments = convert_emitted_attachments(
dispatch.channel_name,
dispatch.capabilities,
attachments,
)
.await;
if !emitted.attachments.is_empty() {
let incoming_attachments = emitted
.attachments
.iter()
.map(|a| crate::channels::IncomingAttachment {
id: a.id.clone(),
kind: crate::channels::AttachmentKind::from_mime_type(&a.mime_type),
mime_type: a.mime_type.clone(),
filename: a.filename.clone(),
size_bytes: a.size_bytes,
source_url: a.source_url.clone(),
storage_key: a.storage_key.clone(),
extracted_text: a.extracted_text.clone(),
data: a.data.clone(),
duration_secs: a.duration_secs,
})
.collect();
msg = msg.with_attachments(incoming_attachments);
}
msg = apply_emitted_metadata(msg, &metadata_json);
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
if is_owner_sender {
// Store for owner-target routing (chat_id etc.)
do_update_broadcast_metadata(
dispatch.channel_name,
dispatch.owner_scope_id,
&metadata_json,
&emitted.metadata_json,
dispatch.last_broadcast_metadata,
dispatch.settings_store,
)
@@ -2569,8 +2555,8 @@ impl WasmChannel {
// Send to stream — no locks held across this await
tracing::info!(
channel = %dispatch.channel_name,
user_id = %user_id,
content_len = content.len(),
user_id = %emitted.user_id,
content_len = emitted.content.len(),
attachment_count = msg.attachments.len(),
"Sending polled message to agent"
);
@@ -2595,7 +2581,6 @@ impl WasmChannel {
struct EmitDispatchContext<'a> {
channel_name: &'a str,
capabilities: &'a ChannelCapabilities,
owner_scope_id: &'a str,
owner_actor_id: Option<&'a str>,
message_tx: &'a RwLock<Option<mpsc::Sender<IncomingMessage>>>,
@@ -3272,38 +3257,6 @@ async fn resolve_channel_host_credentials(
/// Maximum total attachment size (50 MB).
const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 50 * 1024 * 1024;
async fn convert_emitted_attachments(
channel_name: &str,
capabilities: &ChannelCapabilities,
attachments: Vec<crate::channels::wasm::host::Attachment>,
) -> Vec<crate::channels::IncomingAttachment> {
let mut hydrated = attachments;
for attachment in &mut hydrated {
crate::channels::wasm::attachment_hydration::hydrate_attachment_for_channel(
channel_name,
capabilities,
attachment,
)
.await;
}
hydrated
.into_iter()
.map(|attachment| crate::channels::IncomingAttachment {
id: attachment.id,
kind: crate::channels::AttachmentKind::from_mime_type(&attachment.mime_type),
mime_type: attachment.mime_type,
filename: attachment.filename,
size_bytes: attachment.size_bytes,
source_url: attachment.source_url,
storage_key: attachment.storage_key,
extracted_text: attachment.extracted_text,
data: attachment.data,
duration_secs: attachment.duration_secs,
})
.collect()
}
/// Detect MIME type from file extension using the `mime_guess` crate.
fn mime_from_extension(path: &str) -> String {
mime_guess::from_path(path)
@@ -3516,8 +3469,6 @@ mod tests {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
@@ -3534,7 +3485,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "test-channel",
capabilities: &capabilities,
owner_scope_id: "default",
owner_actor_id: None,
message_tx: &message_tx,
@@ -3567,8 +3517,6 @@ mod tests {
// No sender available (channel not started)
let message_tx = Arc::new(tokio::sync::RwLock::new(None));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
@@ -3582,7 +3530,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "test-channel",
capabilities: &capabilities,
owner_scope_id: "default",
owner_actor_id: None,
message_tx: &message_tx,
@@ -4573,8 +4520,6 @@ mod tests {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
@@ -4591,7 +4536,6 @@ mod tests {
source_url: Some("https://api.telegram.org/file/photo123".to_string()),
storage_key: None,
extracted_text: None,
extras_json: String::new(),
data: Vec::new(),
duration_secs: None,
},
@@ -4603,7 +4547,6 @@ mod tests {
source_url: None,
storage_key: Some("store/doc456".to_string()),
extracted_text: Some("Report contents...".to_string()),
extras_json: String::new(),
data: Vec::new(),
duration_secs: None,
},
@@ -4616,7 +4559,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "test-channel",
capabilities: &capabilities,
owner_scope_id: "default",
owner_actor_id: None,
message_tx: &message_tx,
@@ -4663,8 +4605,6 @@ mod tests {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("telegram");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
@@ -4680,7 +4620,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "telegram",
capabilities: &capabilities,
owner_scope_id: "owner-scope",
owner_actor_id: Some("telegram-owner"),
message_tx: &message_tx,
@@ -4709,8 +4648,6 @@ mod tests {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("telegram");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
@@ -4725,7 +4662,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "telegram",
capabilities: &capabilities,
owner_scope_id: "owner-scope",
owner_actor_id: Some("telegram-owner"),
message_tx: &message_tx,
@@ -4795,8 +4731,6 @@ mod tests {
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
let capabilities =
crate::channels::wasm::capabilities::ChannelCapabilities::for_channel("test-channel");
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
@@ -4810,7 +4744,6 @@ mod tests {
let result = WasmChannel::dispatch_emitted_messages(
EmitDispatchContext {
channel_name: "test-channel",
capabilities: &capabilities,
owner_scope_id: "default",
owner_actor_id: None,
message_tx: &message_tx,
-1
View File
@@ -47,7 +47,6 @@ pub async fn extensions_list_handler(
&ext,
has_paired,
owner_bound_channels.contains(&ext.name),
ext.requires_binding,
)
} else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay {
Some(if ext.active {
File diff suppressed because it is too large Load Diff
+15 -234
View File
@@ -3056,17 +3056,16 @@ function showConfigureModal(name) {
.then((setup) => {
const secrets = Array.isArray(setup.secrets) ? setup.secrets : [];
const setupFields = Array.isArray(setup.fields) ? setup.fields : [];
const interactiveLogin = setup.interactive_login || null;
if (secrets.length === 0 && setupFields.length === 0 && !interactiveLogin) {
showToast(I18n.t('extensions.noConfigNeeded', { name: name }), 'info');
if (secrets.length === 0 && setupFields.length === 0) {
showToast('No configuration needed for ' + name, 'info');
return;
}
renderConfigureModal(name, secrets, setupFields, interactiveLogin);
renderConfigureModal(name, secrets, setupFields);
})
.catch((err) => showToast(I18n.t('error.loadFailed', { message: err.message }), 'error'));
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
}
function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
function renderConfigureModal(name, secrets, setupFields) {
closeConfigureModal();
const overlay = document.createElement('div');
overlay.className = 'configure-overlay';
@@ -3092,13 +3091,6 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
modal.appendChild(hint);
}
if (interactiveLogin) {
const hint = document.createElement('div');
hint.className = 'configure-hint';
hint.textContent = interactiveLoginHintText(name, interactiveLogin);
modal.appendChild(hint);
}
const form = document.createElement('div');
form.className = 'configure-form';
@@ -3188,13 +3180,7 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
fields.push({ kind: 'field', name: setupField.name, input: input });
}
if (fields.length > 0) {
modal.appendChild(form);
}
if (interactiveLogin) {
modal.appendChild(renderInteractiveLoginPanel(name));
}
modal.appendChild(form);
const error = document.createElement('div');
error.className = 'configure-inline-error';
@@ -3209,23 +3195,11 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
const actions = document.createElement('div');
actions.className = 'configure-actions';
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.dataset.defaultLabel = interactiveLoginDefaultLabel(name, interactiveLogin);
loginBtn.textContent = loginBtn.dataset.defaultLabel;
loginBtn.dataset.interactiveLogin = 'true';
loginBtn.addEventListener('click', () => startInteractiveLogin(name, overlay));
actions.appendChild(loginBtn);
}
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);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-ext remove';
@@ -3237,200 +3211,7 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) {
overlay.appendChild(modal);
document.body.appendChild(overlay);
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(name) {
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 =
name === 'wechat' ? I18n.t('config.wechatQrTitle') : I18n.t('auth.connect');
panel.appendChild(title);
const status = document.createElement('div');
status.className = 'configure-verification-instructions';
status.textContent = interactiveLoginStatusText(name, null);
status.dataset.qrStatus = 'true';
panel.appendChild(status);
const link = document.createElement('a');
link.className = 'configure-verification-link';
link.textContent =
name === 'wechat' ? I18n.t('config.wechatQrOpen') : I18n.t('auth.connect');
link.target = '_blank';
link.rel = 'noreferrer noopener';
link.style.display = 'none';
link.dataset.qrLink = 'true';
panel.appendChild(link);
return panel;
}
function interactiveLoginHintText(name, interactiveLogin) {
if (name === 'wechat') return I18n.t('config.wechatHint');
return (interactiveLogin && interactiveLogin.instructions) || '';
}
function interactiveLoginDefaultLabel(name, interactiveLogin) {
if (name === 'wechat') return I18n.t('config.wechatConnect');
return (interactiveLogin && interactiveLogin.button_label) || I18n.t('auth.connect');
}
function interactiveLoginWaitingLabel(name) {
if (name === 'wechat') return I18n.t('config.wechatWaiting');
return I18n.t('status.connecting');
}
function interactiveLoginStatusText(name, res) {
if (name !== 'wechat') return (res && res.message) || '';
if (!res) return I18n.t('config.wechatQrIntro');
switch (res.status) {
case 'pending':
return res.qr_code_url ? I18n.t('config.wechatQrReady') : I18n.t('config.wechatQrWaiting');
case 'scanned':
return I18n.t('config.wechatQrScanned');
case 'refreshed':
return I18n.t('config.wechatQrRefreshed');
case 'succeeded':
return I18n.t('config.wechatConnected');
case 'failed':
return res.message || I18n.t('config.wechatQrFailed');
default:
return res.message || I18n.t('config.wechatQrIntro');
}
}
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 name = overlay && overlay.dataset ? overlay.dataset.extensionName : '';
const status = panel.querySelector('[data-qr-status="true"]');
const link = panel.querySelector('[data-qr-link="true"]');
panel.style.display = '';
if (status) {
if (name === 'wechat' && res.status === 'refreshed') {
status.textContent = I18n.t('config.wechatQrRefreshedHint');
} else {
status.textContent = interactiveLoginStatusText(name, res);
}
}
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;
loginBtn.textContent = label || loginBtn.dataset.defaultLabel || I18n.t('auth.connect');
}
function startInteractiveLogin(name, overlay) {
if (!overlay || !document.body.contains(overlay)) return;
clearConfigureInlineError(overlay);
setConfigureInlineStatus(
overlay,
name === 'wechat' ? I18n.t('config.wechatPreparingQr') : I18n.t('status.connecting'),
);
setInteractiveLoginBusy(overlay, true, interactiveLoginWaitingLabel(name));
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);
setConfigureInlineError(
overlay,
res.message || I18n.t('config.interactiveLoginStartFailed'),
);
setConfigureInlineStatus(overlay, '');
return;
}
overlay.dataset.interactiveLoginSessionId = res.session_id;
updateInteractiveLoginPanel(overlay, res);
setConfigureInlineStatus(overlay, interactiveLoginStatusText(name, res));
pollInteractiveLogin(name, overlay, res.session_id);
})
.catch((err) => {
if (!overlay || !document.body.contains(overlay)) return;
setInteractiveLoginBusy(overlay, false);
setConfigureInlineError(
overlay,
err.message || I18n.t('config.interactiveLoginStartFailed'),
);
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;
updateInteractiveLoginPanel(overlay, res);
setConfigureInlineStatus(overlay, interactiveLoginStatusText(name, res));
if (res.status === 'pending' || res.status === 'scanned' || res.status === 'refreshed') {
if (res.status === 'refreshed') {
setInteractiveLoginBusy(overlay, true, interactiveLoginWaitingLabel(name));
}
window.setTimeout(function() {
pollInteractiveLogin(name, overlay, sessionId);
}, 0);
return;
}
if (res.success && res.activated) {
closeConfigureModal(name);
showToast(res.message || I18n.t('config.connectedSuccess', { name: name }), 'success');
refreshCurrentSettingsTab();
return;
}
setInteractiveLoginBusy(overlay, false);
setConfigureInlineError(overlay, res.message || I18n.t('config.interactiveLoginFailed'));
setConfigureInlineStatus(overlay, '');
})
.catch((err) => {
if (!overlay || !document.body.contains(overlay)) return;
if (overlay.dataset.interactiveLoginSessionId !== sessionId) return;
setInteractiveLoginBusy(overlay, false);
setConfigureInlineError(overlay, err.message || I18n.t('config.interactiveLoginFailed'));
setConfigureInlineStatus(overlay, '');
});
if (fields.length > 0) fields[0].input.focus();
}
function renderTelegramVerificationChallenge(overlay, verification) {
@@ -3736,9 +3517,9 @@ function renderWasmChannelStepper(ext) {
var status = ext.activation_status || 'installed';
var steps = [
{ label: I18n.t('status.installed'), key: 'installed' },
{ label: I18n.t('status.configured'), key: 'configured' },
{ label: status === 'pairing' ? I18n.t('status.pairingShort') : I18n.t('status.active'), key: 'active' },
{ label: 'Installed', key: 'installed' },
{ label: 'Configured', key: 'configured' },
{ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' },
];
var reachedIdx;
-19
View File
@@ -54,9 +54,7 @@ I18n.register('en', {
'status.restart': 'Restart',
'status.active': 'Active',
'status.installed': 'Installed',
'status.configured': 'Configured',
'status.awaitingPairing': 'Awaiting Pairing',
'status.pairingShort': 'Pairing',
// Dashboard
'dashboard.connections': 'Connections',
@@ -361,23 +359,6 @@ I18n.register('en', {
'config.telegramStartOver': 'Start over',
'config.telegramStartOverHint': 'Telegram verification did not complete. Click Start over to generate a new code and try again.',
'config.telegramOpenBot': 'Open bot in Telegram',
'config.wechatHint': 'Open the WeChat QR page in a new tab, then scan and confirm in WeChat.',
'config.wechatConnect': 'Open QR Page',
'config.wechatWaiting': 'Waiting for scan...',
'config.wechatPreparingQr': 'Preparing WeChat QR page...',
'config.wechatQrTitle': 'Open WeChat QR Page',
'config.wechatQrOpen': 'Open QR Page',
'config.wechatQrIntro': 'The QR flow opens in a separate tab.',
'config.wechatQrReady': 'QR page is ready. Open it in a new tab, then scan and confirm in WeChat.',
'config.wechatQrWaiting': 'Preparing the WeChat QR page...',
'config.wechatQrScanned': 'QR scanned. Confirm the login in WeChat.',
'config.wechatQrRefreshed': 'QR page refreshed.',
'config.wechatQrRefreshedHint': 'The previous QR page expired. Open the new page and scan again.',
'config.wechatConnected': 'WeChat connected.',
'config.wechatQrFailed': 'WeChat connection failed.',
'config.interactiveLoginStartFailed': 'Failed to start interactive login',
'config.interactiveLoginFailed': 'Interactive login failed',
'config.connectedSuccess': '{name} connected successfully',
'config.optional': ' (optional)',
'config.alreadySet': '(already set — leave empty to keep)',
'config.alreadyConfigured': 'Already configured',
-20
View File
@@ -54,9 +54,7 @@ I18n.register('zh-CN', {
'status.restart': '重启',
'status.active': '已激活',
'status.installed': '已安装',
'status.configured': '已配置',
'status.awaitingPairing': '等待配对',
'status.pairingShort': '配对中',
// 仪表盘
'dashboard.connections': '连接数',
@@ -360,24 +358,6 @@ I18n.register('zh-CN', {
'config.telegramCommandLabel': '请在 Telegram 中发送:',
'config.telegramStartOver': '重新开始',
'config.telegramStartOverHint': 'Telegram 验证未完成。点击“重新开始”以生成新的验证码并重试。',
'config.telegramOpenBot': '在 Telegram 中打开机器人',
'config.wechatHint': '在新标签页打开微信扫码页,然后在微信里扫码并确认。',
'config.wechatConnect': '打开扫码页',
'config.wechatWaiting': '等待扫码中...',
'config.wechatPreparingQr': '正在准备微信扫码页...',
'config.wechatQrTitle': '打开微信扫码页',
'config.wechatQrOpen': '打开扫码页',
'config.wechatQrIntro': '扫码流程会在新标签页中打开。',
'config.wechatQrReady': '扫码页已就绪。请在新标签页打开后,用微信扫码并确认。',
'config.wechatQrWaiting': '正在准备微信扫码页...',
'config.wechatQrScanned': '已扫码,请在微信中确认登录。',
'config.wechatQrRefreshed': '扫码页已刷新。',
'config.wechatQrRefreshedHint': '之前的扫码页已过期,请打开新页面重新扫码。',
'config.wechatConnected': '微信已连接。',
'config.wechatQrFailed': '微信连接失败。',
'config.interactiveLoginStartFailed': '启动交互式登录失败',
'config.interactiveLoginFailed': '交互式登录失败',
'config.connectedSuccess': '{name} 连接成功',
'config.optional': '(可选)',
'config.alreadySet': '(已设置 — 留空以保持不变)',
'config.alreadyConfigured': '已配置',
+5 -38
View File
@@ -2961,18 +2961,15 @@ body {
/* WASM channel setup stepper */
.ext-stepper {
display: flex;
align-items: flex-start;
align-items: center;
gap: 0;
margin: 8px 0 4px;
min-width: 0;
}
.stepper-step {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1 1 0;
gap: 4px;
}
.stepper-circle {
@@ -2989,10 +2986,7 @@ body {
.stepper-label {
font-size: var(--text-xs);
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.25;
min-width: 0;
white-space: nowrap;
}
.stepper-step.completed .stepper-circle {
@@ -3049,8 +3043,7 @@ body {
height: 2px;
background: var(--border);
margin: 0 4px;
flex: 0 0 20px;
align-self: center;
flex-shrink: 0;
}
.stepper-connector.completed {
@@ -3245,17 +3238,6 @@ 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-verification-title {
font-size: var(--text-sm);
font-weight: 600;
@@ -3280,29 +3262,14 @@ body {
}
.configure-verification-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--accent);
background: var(--accent-subtle);
color: var(--accent, var(--text-link, #4ea3ff));
font-size: var(--text-sm);
font-weight: 600;
text-decoration: none;
transition: background var(--transition-fast), transform 150ms var(--ease-spring);
}
.configure-verification-link:hover {
background: var(--badge-sandbox-bg);
transform: translateY(-1px);
text-decoration: none;
}
.configure-verification-link:active {
transform: scale(0.98);
text-decoration: underline;
}
.configure-inline-error {
+1 -30
View File
@@ -306,7 +306,6 @@ pub fn classify_wasm_channel_activation(
ext: &crate::extensions::InstalledExtension,
has_paired: bool,
has_owner_binding: bool,
requires_binding: bool,
) -> Option<ExtensionActivationStatus> {
if ext.kind != crate::extensions::ExtensionKind::WasmChannel {
return None;
@@ -317,7 +316,7 @@ pub fn classify_wasm_channel_activation(
} else if !ext.authenticated {
ExtensionActivationStatus::Installed
} else if ext.active {
if !requires_binding || has_paired || has_owner_binding {
if has_paired || has_owner_binding {
ExtensionActivationStatus::Active
} else {
ExtensionActivationStatus::Pairing
@@ -387,8 +386,6 @@ 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)]
@@ -421,32 +418,6 @@ 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,
+5 -184
View File
@@ -473,8 +473,7 @@ pub struct PendingOAuthFlow {
pub secrets: Arc<dyn SecretsStore + Send + Sync>,
/// SSE broadcast manager for notifying the web UI.
pub sse_manager: Option<Arc<crate::channels::web::sse::SseManager>>,
/// OAuth proxy auth token for authenticating with the hosted token exchange proxy.
/// Kept as `gateway_token` for public API compatibility.
/// Gateway auth token for authenticating with the platform token exchange proxy.
pub gateway_token: Option<String>,
/// Additional form params for the token exchange request.
/// Used for provider-specific requirements such as RFC 8707 `resource`.
@@ -497,12 +496,6 @@ impl std::fmt::Debug for PendingOAuthFlow {
}
}
impl PendingOAuthFlow {
pub fn oauth_proxy_auth_token(&self) -> Option<&str> {
self.gateway_token.as_deref()
}
}
/// Thread-safe registry of pending OAuth flows, keyed by CSRF `state` parameter.
pub type PendingOAuthRegistry = Arc<RwLock<HashMap<String, PendingOAuthFlow>>>;
@@ -536,22 +529,6 @@ pub fn exchange_proxy_url() -> Option<String> {
.filter(|url| !url.is_empty())
}
/// Returns the configured OAuth proxy auth token, if any.
///
/// New hosted infra can inject a dedicated shared proxy secret via
/// `IRONCLAW_OAUTH_PROXY_AUTH_TOKEN`. Existing hosted instances continue to
/// work by falling back to `GATEWAY_AUTH_TOKEN`.
pub fn oauth_proxy_auth_token() -> Option<String> {
fn normalized_env_value(key: &str) -> Option<String> {
crate::config::helpers::env_or_override(key)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
normalized_env_value("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN")
.or_else(|| normalized_env_value("GATEWAY_AUTH_TOKEN"))
}
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
@@ -697,8 +674,6 @@ pub fn strip_instance_prefix(state: &str) -> &str {
pub struct ProxyTokenExchangeRequest<'a> {
pub proxy_url: &'a str,
/// OAuth proxy auth token.
/// Kept as `gateway_token` for public API compatibility.
pub gateway_token: &'a str,
pub token_url: &'a str,
pub client_id: &'a str,
@@ -712,8 +687,6 @@ pub struct ProxyTokenExchangeRequest<'a> {
pub struct ProxyRefreshTokenRequest<'a> {
pub proxy_url: &'a str,
/// OAuth proxy auth token.
/// Kept as `gateway_token` for public API compatibility.
pub gateway_token: &'a str,
pub token_url: &'a str,
pub client_id: &'a str,
@@ -756,7 +729,7 @@ fn oauth_token_response_from_json(
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
///
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
/// Authenticated via the gateway auth token (Bearer header). The caller may
/// either rely on proxy-side secret lookup or forward a `client_secret` when
/// the provider requires it.
///
@@ -768,7 +741,7 @@ pub async fn exchange_via_proxy(
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"OAuth proxy auth token is required for proxy token exchange".to_string(),
"Gateway auth token is required for proxy token exchange".to_string(),
));
}
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
@@ -823,7 +796,7 @@ pub async fn exchange_via_proxy(
/// Refresh an OAuth access token via the platform's token refresh proxy.
///
/// Authenticated via an OAuth proxy auth token (Bearer header). The caller may
/// Authenticated via the gateway auth token (Bearer header). The caller may
/// either rely on proxy-side secret lookup or forward a `client_secret` when
/// the provider requires it.
pub async fn refresh_token_via_proxy(
@@ -831,7 +804,7 @@ pub async fn refresh_token_via_proxy(
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
if request.gateway_token.is_empty() {
return Err(OAuthCallbackError::Io(
"OAuth proxy auth token is required for proxy token refresh".to_string(),
"Gateway auth token is required for proxy token refresh".to_string(),
));
}
@@ -1037,37 +1010,6 @@ mod tests {
}
}
struct EnvVarGuard {
key: &'static str,
original: Option<String>,
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
if let Some(ref value) = self.original {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
}
fn set_env_var(key: &'static str, value: Option<&str>) -> EnvVarGuard {
let original = std::env::var(key).ok();
// SAFETY: Under ENV_MUTEX, no concurrent env access.
unsafe {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
EnvVarGuard { key, original }
}
#[test]
fn test_hosted_proxy_client_secret_suppresses_builtin_secret() {
let builtin = builtin_credentials("google_oauth_token").expect("google builtin creds");
@@ -1088,79 +1030,6 @@ mod tests {
assert_eq!(result, client_secret);
}
#[tokio::test]
async fn test_exchange_via_proxy_sends_auth_and_form() {
let server = MockProxyServer::start().await;
let mut extra_token_params = HashMap::new();
extra_token_params.insert("resource".to_string(), "https://mcp.notion.com".to_string());
let response = super::exchange_via_proxy(super::ProxyTokenExchangeRequest {
proxy_url: &server.base_url(),
gateway_token: "shared-oauth-proxy-secret",
code: "auth-code-123",
redirect_uri: "https://oauth.example.com/oauth/callback",
token_url: "https://oauth2.googleapis.com/token",
client_id: TEST_OAUTH_CLIENT_ID,
client_secret: Some(TEST_OAUTH_CLIENT_SECRET),
access_token_field: "access_token",
code_verifier: Some("code-verifier-123"),
extra_token_params: &extra_token_params,
})
.await
.expect("proxy exchange succeeds");
assert_eq!(response.access_token, "proxy-access-token");
assert_eq!(
response.refresh_token.as_deref(),
Some("proxy-refresh-token")
);
assert_eq!(response.expires_in, Some(7200));
let requests = server.requests().await;
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].authorization.as_deref(),
Some("Bearer shared-oauth-proxy-secret")
);
assert_eq!(
requests[0].form.get("code").map(String::as_str),
Some("auth-code-123")
);
assert_eq!(
requests[0].form.get("redirect_uri").map(String::as_str),
Some("https://oauth.example.com/oauth/callback")
);
assert_eq!(
requests[0].form.get("token_url").map(String::as_str),
Some("https://oauth2.googleapis.com/token")
);
assert_eq!(
requests[0].form.get("client_id").map(String::as_str),
Some(TEST_OAUTH_CLIENT_ID)
);
assert_eq!(
requests[0].form.get("client_secret").map(String::as_str),
Some(TEST_OAUTH_CLIENT_SECRET)
);
assert_eq!(
requests[0]
.form
.get("access_token_field")
.map(String::as_str),
Some("access_token")
);
assert_eq!(
requests[0].form.get("code_verifier").map(String::as_str),
Some("code-verifier-123")
);
assert_eq!(
requests[0].form.get("resource").map(String::as_str),
Some("https://mcp.notion.com")
);
server.shutdown().await;
}
#[tokio::test]
async fn test_refresh_token_via_proxy_sends_auth_and_form() {
let server = MockProxyServer::start().await;
@@ -1666,54 +1535,6 @@ mod tests {
}
}
#[test]
fn test_oauth_proxy_auth_token_prefers_dedicated_env() {
let _guard = lock_env();
let _proxy_guard = set_env_var(
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
Some("shared-proxy-secret"),
);
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
assert_eq!(
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
Some("shared-proxy-secret")
);
}
#[test]
fn test_oauth_proxy_auth_token_falls_back_to_gateway_token() {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
assert_eq!(
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
Some("gateway-token")
);
}
#[test]
fn test_oauth_proxy_auth_token_whitespace_dedicated_env_falls_back_to_gateway_token() {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", Some(" "));
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-token"));
assert_eq!(
crate::cli::oauth_defaults::oauth_proxy_auth_token().as_deref(),
Some("gateway-token")
);
}
#[test]
fn test_oauth_proxy_auth_token_returns_none_when_unset() {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _gateway_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
assert_eq!(crate::cli::oauth_defaults::oauth_proxy_auth_token(), None);
}
#[test]
fn test_strip_instance_prefix_with_colon() {
use crate::cli::oauth_defaults::strip_instance_prefix;
-3
View File
@@ -192,9 +192,6 @@ pub struct JobContext {
/// but subsequent tools (e.g., `json`) may need the full output. This
/// stash stores the complete, unsanitized output so tools can reference
/// previous results by ID via `$tool_call_id` parameter syntax.
///
/// Also used for cross-tool implicit state (keys prefixed with `__`) such
/// as `__routine_last_name` for fallback recovery in routine tool chains.
#[serde(skip)]
pub tool_output_stash: Arc<tokio::sync::RwLock<HashMap<String, String>>>,
/// User's preferred timezone (IANA name, e.g. "America/New_York"). Defaults to "UTC".
+53 -922
View File
File diff suppressed because it is too large Load Diff
+11 -55
View File
@@ -2,7 +2,8 @@
//! and activation of channels, tools, and MCP servers.
//!
//! Extensions are the user-facing abstraction that unifies three runtime kinds:
//! - **Channels** (Telegram, Slack, Discord) — messaging integrations (WASM)
//! - **Channels** (Telegram, Slack, Discord) — messaging platform connections
//! and conversation transports (WASM)
//! - **Tools** — sandboxed capabilities (WASM)
//! - **MCP servers** — external API integrations via Model Context Protocol
//!
@@ -19,7 +20,6 @@
pub mod discovery;
pub mod manager;
pub mod registry;
pub(crate) mod wechat_login;
pub use discovery::OnlineDiscovery;
pub use manager::ExtensionManager;
@@ -70,12 +70,12 @@ pub struct RegistryEntry {
/// Where to get this extension.
pub source: ExtensionSource,
/// Fallback source when the primary source fails (e.g., download 404 → build from source).
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_source: Option<Box<ExtensionSource>>,
/// How authentication works.
pub auth_hint: AuthHint,
/// Extension version (semver), if known.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
@@ -88,14 +88,17 @@ pub enum ExtensionSource {
/// Downloadable WASM binary.
WasmDownload {
wasm_url: String,
#[serde(default)]
capabilities_url: Option<String>,
},
/// Build from local source directory.
WasmBuildable {
#[serde(alias = "repo_url")]
source_dir: String,
#[serde(default)]
build_dir: Option<String>,
/// Crate name used to locate the build artifact binary.
#[serde(default)]
crate_name: Option<String>,
},
/// Discovered online (not yet validated for a specific source type).
@@ -387,9 +390,13 @@ impl<'de> Deserialize<'de> for AuthResult {
struct Raw {
name: String,
kind: ExtensionKind,
#[serde(default)]
auth_url: Option<String>,
#[serde(default)]
callback_type: Option<String>,
#[serde(default)]
instructions: Option<String>,
#[serde(default)]
setup_url: Option<String>,
#[serde(default)]
awaiting_token: bool,
@@ -433,52 +440,6 @@ 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(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(skip_serializing_if = "Option::is_none")]
pub qr_code_url: Option<String>,
/// Optional short instructions shown alongside the QR code.
#[serde(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(skip_serializing_if = "Option::is_none")]
pub qr_code_url: Option<String>,
/// Whether the extension was successfully activated as part of login completion.
#[serde(skip_serializing_if = "Option::is_none")]
pub activated: Option<bool>,
}
/// Result of activating an extension.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivateResult {
@@ -546,10 +507,6 @@ pub struct InstalledExtension {
/// Whether this extension has an auth configuration (OAuth or manual token).
#[serde(default)]
pub has_auth: bool,
/// Whether this extension still needs owner binding / pairing before it should
/// be treated as fully active in the UI.
#[serde(default)]
pub requires_binding: bool,
/// Whether this extension is installed locally (false = available in registry but not installed).
#[serde(default = "default_true")]
pub installed: bool,
@@ -1000,7 +957,6 @@ mod tests {
tools: vec!["send_email".to_string(), "read_inbox".to_string()],
needs_setup: true,
has_auth: true,
requires_binding: false,
installed: false,
activation_error: Some("token expired".to_string()),
version: None,
-443
View File
@@ -1,443 +0,0 @@
use std::time::{Duration, Instant};
use reqwest::Client;
use serde::Deserialize;
use uuid::Uuid;
use crate::extensions::{
ExtensionError, InteractiveLoginInfo, InteractiveLoginPollResult, InteractiveLoginStartResult,
};
pub(crate) const WECHAT_CHANNEL_NAME: &str = "wechat";
pub(crate) const WECHAT_BASE_URL_SETTING_PATH: &str = "extensions.wechat.base_url";
pub(crate) const WECHAT_DEFAULT_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
pub(crate) const WECHAT_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 PendingWechatLogin {
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 PendingWechatLogin {
pub fn is_fresh(&self) -> bool {
self.started_at.elapsed() < LOGIN_SESSION_TTL
}
}
#[derive(Debug, Clone)]
pub(crate) struct ConfirmedWechatLogin {
pub bot_token: String,
pub base_url: Option<String>,
pub ilink_bot_id: String,
}
pub(crate) enum WechatLoginPollOutcome {
Pending(InteractiveLoginPollResult),
Confirmed(ConfirmedWechatLogin),
}
#[derive(Debug, Clone, Deserialize)]
struct QrCodeResponse {
qrcode: String,
qrcode_img_content: String,
}
#[derive(Debug, Clone, Deserialize)]
struct QrStatusResponse {
status: String,
bot_token: Option<String>,
ilink_bot_id: Option<String>,
baseurl: Option<String>,
}
pub(crate) fn interactive_login_info() -> InteractiveLoginInfo {
InteractiveLoginInfo {
method: "qr_code".to_string(),
button_label: "Connect WeChat".to_string(),
instructions: Some("Scan the QR code with WeChat to connect this channel.".to_string()),
}
}
pub(crate) fn purge_expired_logins(
sessions: &mut std::collections::HashMap<String, PendingWechatLogin>,
) {
sessions.retain(|_, session| session.is_fresh());
}
pub(crate) async fn start_login(
user_id: &str,
base_url: &str,
bot_type: &str,
) -> Result<(PendingWechatLogin, 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 PendingWechatLogin,
) -> Result<WechatLoginPollOutcome, ExtensionError> {
if !session.is_fresh() {
return Ok(WechatLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "failed".to_string(),
message: "The QR code expired. Start a new WeChat 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,
) -> (PendingWechatLogin, InteractiveLoginStartResult) {
let session_id = Uuid::new_v4().to_string();
let session = PendingWechatLogin {
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: "Open the WeChat QR page to continue.".to_string(),
qr_code_url: Some(qr.qrcode_img_content),
instructions: Some(
"Keep this window open while you scan and confirm on your phone.".to_string(),
),
};
(session, result)
}
fn handle_poll_status(
session: &mut PendingWechatLogin,
status: QrStatusResponse,
refreshed_qr: Option<QrCodeResponse>,
) -> Result<WechatLoginPollOutcome, ExtensionError> {
match status.status.as_str() {
"wait" => Ok(WechatLoginPollOutcome::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(WechatLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "scanned".to_string(),
message: "QR code scanned. Confirm the login in WeChat.".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(WechatLoginPollOutcome::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(
"WeChat 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(WechatLoginPollOutcome::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(
"WeChat login succeeded but no bot account id was returned".to_string(),
)
})?;
let bot_token = bot_token.ok_or_else(|| {
ExtensionError::Other(
"WeChat login succeeded but no bot token was returned".to_string(),
)
})?;
Ok(WechatLoginPollOutcome::Confirmed(ConfirmedWechatLogin {
bot_token,
base_url: status.baseurl.filter(|value| !value.trim().is_empty()),
ilink_bot_id,
}))
}
other => {
tracing::warn!(status = other, "Unexpected WeChat QR status");
Ok(WechatLoginPollOutcome::Pending(
InteractiveLoginPollResult {
session_id: session.session_id.clone(),
status: "failed".to_string(),
message: format!("Unexpected WeChat 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 WeChat login client: {e}")))?;
let response = client
.get(&url)
.send()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to fetch WeChat QR code: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(status = %status, "WeChat QR code request failed");
return Err(ExtensionError::Other(format!(
"WeChat QR code request failed with {status}: {body}"
)));
}
response
.json::<QrCodeResponse>()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to parse WeChat 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 WeChat 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 WeChat QR status: {error}"
)));
}
};
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!(status = %status, "WeChat QR status poll failed");
return Err(ExtensionError::Other(format!(
"WeChat QR status poll failed with {status}: {body}"
)));
}
response
.json::<QrStatusResponse>()
.await
.map_err(|e| ExtensionError::Other(format!("Failed to parse WeChat QR status: {e}")))
}
#[cfg(test)]
mod tests {
use super::{
QrCodeResponse, QrStatusResponse, WechatLoginPollOutcome, 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 {
WechatLoginPollOutcome::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(())
}
WechatLoginPollOutcome::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 {
WechatLoginPollOutcome::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(())
}
WechatLoginPollOutcome::Confirmed(_) => {
Err("expected QR refresh before confirmation".to_string())
}
}
}
}
+64 -6
View File
@@ -1017,8 +1017,11 @@ Example:
"\n\n## Extensions\n\
You can search, install, and activate extensions to add new capabilities:\n\
- **Channels** (Telegram, Slack, Discord) messaging integrations. \
When users ask about connecting a messaging platform, search for it as a channel.\n\
- **Channels** (Telegram, Slack, Discord) connect messaging platforms so users can \
talk to you there. When users ask about connecting a messaging platform, search for it \
as a channel. Channels are not separate send-message tools; use normal assistant output \
to reply in the current conversation, and use the `message` tool only for proactive, \
background, or cross-channel outbound sends.\n\
- **Tools** sandboxed functions that extend your abilities.\n\
- **MCP servers** external API integrations via the Model Context Protocol.\n\n\
Use `tool_search` to find extensions by name. Refer to them by their kind \
@@ -1059,15 +1062,20 @@ Example:
let message_tool_hint = "\
\n\n## Proactive Messaging\n\
For ordinary replies in the current conversation, respond normally without calling `message`.\n\
Send messages via Signal, Telegram, Slack, or other connected channels:\n\
- `content` (required): the message text\n\
- `attachments` (optional): array of file paths to send\n\
- `channel` (optional): which channel to use (signal, telegram, slack, etc.)\n\
- `target` (optional): who to send to (phone number, group ID, etc.)\n\
\nOmit both `channel` and `target` to send to the current conversation.\n\
\nOmit both `channel` and `target` for a proactive follow-up in the current conversation.\n\
Target formats:\n\
- Signal: E.164 phone number (`+1234567890`) or group ID\n\
- Telegram: username or chat ID\n\
- Slack: channel name (`#general`) or user ID\n\
Examples (tool calls use JSON format):\n\
- Reply here: {\"content\": \"Hi!\"}\n\
- Send file here: {\"content\": \"Here's the file\", \"attachments\": [\"/path/to/file.txt\"]}\n\
- Proactive follow-up here: {\"content\": \"Hi again!\"}\n\
- Send file here proactively: {\"content\": \"Here's the file\", \"attachments\": [\"/path/to/file.txt\"]}\n\
- Message a different user: {\"channel\": \"signal\", \"target\": \"+1234567890\", \"content\": \"Hi!\"}\n\
- Message a different group: {\"channel\": \"signal\", \"target\": \"group:abc123\", \"content\": \"Hi!\"}";
@@ -1105,7 +1113,9 @@ Examples (tool calls use JSON format):\n\
format!(
"\n\n## Current Conversation\n\
This is who you're talking to (omit 'target' to send here):\n{}",
This is who you're talking to in the active conversation. Use normal assistant \
output to reply here; only use the `message` tool for proactive, background, or \
cross-channel outbound sends:\n{}",
lines.join("\n")
)
}
@@ -2452,6 +2462,54 @@ That's my plan."#;
);
}
#[test]
fn test_extensions_section_clarifies_channels_are_not_send_tools() {
let reasoning = make_test_reasoning();
let tool_defs = vec![ToolDefinition {
name: "tool_search".to_string(),
description: "Search extensions".to_string(),
parameters: serde_json::json!({}),
}];
let section = reasoning.build_extensions_section_for_tools(&tool_defs);
assert!(section.contains("connect messaging platforms so users can talk to you there"));
assert!(section.contains("Channels are not separate send-message tools"));
assert!(
section.contains("use normal assistant output to reply in the current conversation")
);
assert!(section.contains(
"`message` tool only for proactive, background, or cross-channel outbound sends"
));
}
#[test]
fn test_channel_section_separates_normal_replies_from_message_tool() {
let reasoning = make_test_reasoning().with_channel("telegram");
let section = reasoning.build_channel_section();
assert!(section.contains("respond normally without calling `message`"));
assert!(section.contains("proactive follow-up in the current conversation"));
assert!(section.contains("Target formats:"));
assert!(section.contains("Signal: E.164 phone number"));
assert!(section.contains("Telegram: username or chat ID"));
assert!(section.contains("Slack: channel name"));
assert!(section.contains("Proactive follow-up here"));
}
#[test]
fn test_current_conversation_section_does_not_imply_message_tool_for_replies() {
let reasoning = make_test_reasoning()
.with_channel("telegram")
.with_conversation_data("User", "telegram-user");
let section = reasoning.build_conversation_section();
assert!(section.contains("Use normal assistant output to reply here"));
assert!(section.contains(
"only use the `message` tool for proactive, background, or cross-channel outbound sends"
));
assert!(!section.contains("omit 'target' to send here"));
}
// ---- plan/evaluate bypass clean_response (Bug #564-2) ----
#[test]
+5 -1
View File
@@ -119,7 +119,6 @@ impl TranscriptionMiddleware {
Some(f) => f,
None => {
tracing::warn!(
attachment_id = %attachment.id,
mime = %attachment.mime_type,
"Skipping audio attachment with unsupported format"
);
@@ -129,6 +128,11 @@ impl TranscriptionMiddleware {
match self.provider.transcribe(&attachment.data, format).await {
Ok(text) => {
tracing::info!(
attachment_id = %attachment.id,
text_len = text.len(),
"Transcribed audio attachment"
);
transcriptions.push((i, text));
}
Err(e) => {
+16 -2
View File
@@ -31,8 +31,11 @@ impl Tool for ToolSearchTool {
fn description(&self) -> &str {
"Search for available extensions to add new capabilities. Extensions include \
channels (Telegram, Slack, Discord for messaging), tools, and MCP servers. \
Use discover:true to search online if the built-in registry has no results."
channels (Telegram, Slack, Discord connect messaging platforms so IronClaw can \
receive and reply there), tools, and MCP servers. Use `tool_install` and \
`tool_activate` to install and enable channels; use the `message` tool for proactive \
outbound sends. Use discover:true to search online if the built-in registry has no \
results."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -634,6 +637,17 @@ mod tests {
assert!(schema["properties"].get("query").is_some());
}
#[test]
fn test_tool_search_description_clarifies_channel_setup_vs_sending() {
let tool = ToolSearchTool {
manager: test_manager_stub(),
};
let description = tool.description();
assert!(description.contains("Use `tool_install` and `tool_activate`"));
assert!(description.contains("use the `message` tool for proactive outbound sends"));
}
#[test]
fn test_tool_install_schema() {
use crate::tools::tool::ApprovalRequirement;
+13 -5
View File
@@ -181,11 +181,15 @@ impl Tool for MessageTool {
}
fn description(&self) -> &str {
"Send a message to a channel. If channel/target omitted, uses the current conversation's \
channel and sender/group. Use to proactively message users on any connected channel. \
"Send a proactive message to a channel. Use normal assistant output to reply in the \
active conversation; use this tool for proactive notifications, routine/background \
follow-ups, attachments, or sending to a different channel/recipient. If channel/target \
are omitted, reuses the current conversation's channel and sender/group when available. \
If you provide `target` without `channel` and no scoped channel can be resolved, the \
message may be broadcast across connected channels instead of sent to just one. \
Supports file attachments: first download the file with the http tool using save_to \
(e.g., http GET https://picsum.photos/800/600 save_to=/tmp/photo.jpg), then pass \
the file path in the attachments array. Images are sent as photos on Telegram. \
(e.g., http GET https://picsum.photos/800/600 save_to=/tmp/photo.jpg), then pass the \
file path in the attachments array. Images are sent as photos on Telegram. \
- Signal: target accepts E.164 (+1234567890) or group ID \
- Telegram: target accepts username or chat ID \
- Slack: target accepts channel (#general) or user ID"
@@ -451,7 +455,11 @@ mod tests {
#[test]
fn message_tool_description() {
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
assert!(!tool.description().is_empty());
let description = tool.description();
assert!(!description.is_empty());
assert!(description.contains("Use normal assistant output to reply"));
assert!(description.contains("proactive notifications"));
assert!(description.contains("provide `target` without `channel`"));
}
#[test]
+3 -35
View File
@@ -650,23 +650,6 @@ pub(crate) fn routine_update_parameters_schema() -> Value {
})
}
const ROUTINE_LAST_NAME_STASH_KEY: &str = "__routine_last_name";
async fn stash_last_routine_name(ctx: &JobContext, name: &str) {
ctx.tool_output_stash
.write()
.await
.insert(ROUTINE_LAST_NAME_STASH_KEY.to_string(), name.to_string());
}
async fn restore_last_routine_name(ctx: &JobContext) -> Option<String> {
ctx.tool_output_stash
.read()
.await
.get(ROUTINE_LAST_NAME_STASH_KEY)
.cloned()
}
fn nested_object<'a>(params: &'a Value, field: &str) -> Option<&'a Map<String, Value>> {
params.get(field).and_then(Value::as_object)
}
@@ -1110,7 +1093,6 @@ impl Tool for RoutineCreateTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let normalized = parse_routine_create_request(&params)?;
stash_last_routine_name(ctx, &normalized.name).await;
let trigger = build_routine_trigger(&normalized.trigger);
let action =
build_routine_action(&normalized.name, &normalized.prompt, &normalized.execution);
@@ -1292,7 +1274,6 @@ impl Tool for RoutineUpdateTool {
let start = std::time::Instant::now();
let name = require_str(&params, "name")?;
stash_last_routine_name(ctx, name).await;
let mut routine = self
.store
@@ -1430,24 +1411,11 @@ impl Tool for RoutineDeleteTool {
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let name = if let Some(name) = params.get("name").and_then(|v| v.as_str()) {
if name.trim().is_empty() {
return Err(ToolError::InvalidParameters(
"'name' parameter cannot be empty".to_string(),
));
}
name.to_string()
} else {
restore_last_routine_name(ctx).await.ok_or_else(|| {
ToolError::InvalidParameters(
"missing 'name' parameter and no previous routine target to infer".to_string(),
)
})?
};
let name = require_str(&params, "name")?;
let routine = self
.store
.get_routine_by_name(&ctx.user_id, &name)
.get_routine_by_name(&ctx.user_id, name)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
@@ -1462,7 +1430,7 @@ impl Tool for RoutineDeleteTool {
self.engine.refresh_event_cache().await;
let result = serde_json::json!({
"name": &name,
"name": name,
"deleted": deleted,
});
+1 -19
View File
@@ -117,11 +117,6 @@ impl McpClient {
/// The config must use HTTP transport (the default); for stdio/UDS use `new_with_transport`.
///
/// Returns an error if the config uses a non-HTTP transport.
///
/// **Note:** The session manager is NOT wired into the transport. For
/// production use, prefer `create_client_from_config()` which constructs
/// the transport with session tracking.
#[cfg(test)]
pub fn new_with_config(config: McpServerConfig) -> Result<Self, ToolError> {
if !matches!(
config.effective_transport(),
@@ -219,14 +214,7 @@ impl McpClient {
}
}
/// Attach a session manager to the **client** only.
///
/// **Warning:** This does NOT wire the session manager into the underlying
/// `HttpMcpTransport`, so the transport will not capture `Mcp-Session-Id`
/// from responses. For production use, construct the transport with
/// `HttpMcpTransport::with_session_manager()` and pass it to
/// `new_with_transport()` instead. See `create_client_from_config()`.
#[cfg(test)]
/// Attach a session manager for Streamable HTTP session tracking.
pub fn with_session_manager(mut self, session_manager: Arc<McpSessionManager>) -> Self {
self.session_manager = Some(session_manager);
self
@@ -247,12 +235,6 @@ impl McpClient {
self.session_manager.is_some()
}
/// Get the underlying transport (test-only).
#[cfg(test)]
pub(crate) fn transport(&self) -> &Arc<dyn McpTransport> {
&self.transport
}
/// Get the next request ID.
fn next_request_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::SeqCst)
+16 -101
View File
@@ -7,7 +7,6 @@ use std::sync::Arc;
use crate::secrets::SecretsStore;
use crate::tools::mcp::config::{EffectiveTransport, McpServerConfig};
use crate::tools::mcp::http_transport::HttpMcpTransport;
use crate::tools::mcp::{McpClient, McpProcessManager, McpSessionManager, McpTransport};
/// Error returned when MCP client creation fails.
@@ -79,37 +78,33 @@ pub async fn create_client_from_config(
Err(McpFactoryError::UnixNotSupported { name: server_name })
}
EffectiveTransport::Http => {
// Authenticated (OAuth) path: tokens exist or server requires auth.
if let Some(ref secrets) = secrets {
let has_tokens =
crate::tools::mcp::is_authenticated(&server, secrets, user_id).await;
if has_tokens || server.requires_auth() {
return Ok(McpClient::new_authenticated(
Ok(McpClient::new_authenticated(
server,
Arc::clone(session_manager),
Arc::clone(secrets),
user_id,
));
))
} else {
Ok(McpClient::new_with_config(server)
.map_err(|e| McpFactoryError::InvalidConfig {
name: server_name.clone(),
reason: e.to_string(),
})?
.with_session_manager(Arc::clone(session_manager)))
}
} else {
Ok(McpClient::new_with_config(server)
.map_err(|e| McpFactoryError::InvalidConfig {
name: server_name,
reason: e.to_string(),
})?
.with_session_manager(Arc::clone(session_manager)))
}
// Non-OAuth HTTP: wire the session manager into the *transport* so
// it captures `Mcp-Session-Id` from responses. Passing it only to
// the client (via `with_session_manager`) is not enough — the
// transport must know about it to read/write the header.
let transport = Arc::new(
HttpMcpTransport::new(server.url.clone(), server.name.clone())
.with_session_manager(Arc::clone(session_manager)),
);
Ok(McpClient::new_with_transport(
server.name.clone(),
transport,
Some(Arc::clone(session_manager)),
secrets,
user_id,
Some(server),
))
}
}
}
@@ -139,84 +134,4 @@ mod tests {
"non-OAuth HTTP clients must carry a session manager"
);
}
/// Regression test: the factory must wire the session manager into the
/// *transport*, not just the client. Otherwise the transport never
/// captures `Mcp-Session-Id` from responses and subsequent requests
/// lack the header, causing the server to reject them.
#[tokio::test]
async fn test_factory_non_oauth_http_transport_captures_session_id() {
use axum::http::header::HeaderName;
use axum::{Router, http::StatusCode, response::IntoResponse, routing::post};
use tokio::net::TcpListener;
const SESSION_ID: &str = "test-session-abc123";
async fn session_echo() -> impl IntoResponse {
let body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": {}
})
.to_string();
(
StatusCode::OK,
[(
HeaderName::from_static("mcp-session-id"),
SESSION_ID.to_string(),
)],
body,
)
}
let app = Router::new().route("/", post(session_echo));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let server = McpServerConfig::new("session-test", &url);
let session_manager = Arc::new(McpSessionManager::new());
let process_manager = Arc::new(McpProcessManager::new());
let client = create_client_from_config(
server,
&session_manager,
&process_manager,
None,
"test-user",
)
.await
.expect("factory should succeed for HTTP config");
// Pre-create a session entry so that update_session_id has something to update.
// In production, the MCP initialize handshake calls get_or_create before responses arrive.
session_manager.get_or_create("session-test", &url).await;
// Send a request through the client's transport to trigger session capture.
use crate::tools::mcp::protocol::McpRequest;
let request = McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(1),
method: "test".to_string(),
params: Some(serde_json::json!({})),
};
let headers = std::collections::HashMap::new();
client
.transport()
.send(&request, &headers)
.await
.expect("request should succeed");
// Verify the session manager captured the session ID from the response.
let captured = session_manager.get_session_id("session-test").await;
assert_eq!(
captured.as_deref(),
Some(SESSION_ID),
"transport must capture Mcp-Session-Id into session manager"
);
}
}
-28
View File
@@ -494,34 +494,6 @@ mod tests {
assert_eq!(echoed["authorization"], "Bearer oauth-token");
}
/// Regression test for #1436: 202 Accepted responses for notifications
/// were parsed as JSON, causing "Failed to parse MCP response" errors
/// that broke the MCP session handshake.
#[tokio::test]
async fn test_wire_202_accepted_for_notification() {
use axum::{Router, http::StatusCode, routing::post};
use tokio::net::TcpListener;
async fn accept_notification() -> StatusCode {
StatusCode::ACCEPTED
}
let app = Router::new().route("/", post(accept_notification));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://127.0.0.1:{}", addr.port());
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let transport = HttpMcpTransport::new(&url, "test-202");
let request = McpRequest::initialized_notification();
let response = transport.send(&request, &HashMap::new()).await.unwrap();
assert!(response.result.is_none());
assert!(response.error.is_none());
}
#[tokio::test]
async fn test_wire_custom_auth_preserved_when_no_per_request_auth() {
let (url, _handle) = spawn_echo_server().await;
+4 -51
View File
@@ -446,14 +446,16 @@ fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefr
builtin.as_ref(),
exchange_proxy_url.is_some(),
);
let oauth_proxy_auth_token = crate::cli::oauth_defaults::oauth_proxy_auth_token();
let gateway_token = crate::config::helpers::env_or_override("GATEWAY_AUTH_TOKEN")
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
Some(OAuthRefreshConfig {
token_url: oauth.token_url.clone(),
client_id,
client_secret,
exchange_proxy_url,
gateway_token: oauth_proxy_auth_token,
gateway_token,
secret_name: auth.secret_name.clone(),
provider: auth.provider.clone(),
})
@@ -889,11 +891,6 @@ mod tests {
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
};
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "google_oauth_token".to_string(),
@@ -985,7 +982,6 @@ mod tests {
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
// google_oauth_token should fall back to built-in credentials
let caps = CapabilitiesFile {
@@ -1025,7 +1021,6 @@ mod tests {
Some("https://compose-api.example.com"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _client_id_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
@@ -1066,7 +1061,6 @@ mod tests {
Some("https://compose-api.example.com"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let _oauth_proxy_token_guard = set_env_var("IRONCLAW_OAUTH_PROXY_AUTH_TOKEN", None);
let _client_id_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
let _client_secret_guard =
@@ -1101,47 +1095,6 @@ mod tests {
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
}
#[test]
fn test_resolve_oauth_refresh_config_hosted_proxy_prefers_dedicated_proxy_auth_token() {
use crate::tools::wasm::capabilities_schema::{
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
};
let _guard = lock_env();
let _proxy_guard = set_env_var(
"IRONCLAW_OAUTH_EXCHANGE_URL",
Some("https://compose-api.example.com"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let _oauth_proxy_token_guard = set_env_var(
"IRONCLAW_OAUTH_PROXY_AUTH_TOKEN",
Some("shared-oauth-proxy-secret"),
);
let _client_id_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "google_oauth_token".to_string(),
provider: Some("google".to_string()),
oauth: Some(OAuthConfigSchema {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
token_url: "https://oauth2.googleapis.com/token".to_string(),
client_id_env: Some("GOOGLE_OAUTH_CLIENT_ID".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let config = super::resolve_oauth_refresh_config(&caps).expect("hosted oauth config");
assert_eq!(
config.gateway_token.as_deref(),
Some("shared-oauth-proxy-secret")
);
}
// ---------------------------------------------------------------
// Security regression tests
// ---------------------------------------------------------------
+5 -13
View File
@@ -62,8 +62,7 @@ pub struct OAuthRefreshConfig {
pub client_secret: Option<String>,
/// Hosted OAuth proxy base URL (e.g., "http://host.docker.internal:8080").
pub exchange_proxy_url: Option<String>,
/// OAuth proxy auth token for authenticating with the hosted OAuth proxy.
/// Kept as `gateway_token` for public API compatibility.
/// Gateway auth token for authenticating with the hosted OAuth proxy.
pub gateway_token: Option<String>,
/// Secret name of the access token (e.g., "google_oauth_token").
/// The refresh token lives at `{secret_name}_refresh_token`.
@@ -72,12 +71,6 @@ pub struct OAuthRefreshConfig {
pub provider: Option<String>,
}
impl OAuthRefreshConfig {
fn oauth_proxy_auth_token(&self) -> Option<&str> {
self.gateway_token.as_deref()
}
}
/// Pre-resolved credential for host-based injection.
///
/// Built before each WASM execution by decrypting secrets from the store.
@@ -1225,9 +1218,9 @@ async fn refresh_oauth_token(
let refresh_name = format!("{}_refresh_token", config.secret_name);
if let Some(proxy_url) = config.exchange_proxy_url.as_deref() {
let Some(oauth_proxy_auth_token) = config.oauth_proxy_auth_token() else {
let Some(gateway_token) = config.gateway_token.as_deref() else {
tracing::warn!(
"OAuth refresh proxy is configured, but no OAuth proxy auth token is available"
"OAuth refresh proxy is configured, but no gateway auth token is available"
);
return false;
};
@@ -1242,7 +1235,7 @@ async fn refresh_oauth_token(
let token_response = match oauth_defaults::refresh_token_via_proxy(
oauth_defaults::ProxyRefreshTokenRequest {
proxy_url,
gateway_token: oauth_proxy_auth_token,
gateway_token,
token_url: &config.token_url,
client_id: &config.client_id,
client_secret: config.client_secret.as_deref(),
@@ -2711,8 +2704,7 @@ mod tests {
}
#[tokio::test]
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_oauth_proxy_auth_token()
{
async fn test_resolve_host_credentials_skips_refresh_token_lookup_without_gateway_token() {
use crate::secrets::{
CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore,
};
+112 -41
View File
@@ -13,7 +13,7 @@ mod tests {
use ironclaw::agent::routine::{RoutineAction, Trigger};
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
use crate::support::trace_llm::{LlmTrace, TraceResponse, TraceStep, TraceToolCall, TraceTurn};
// -----------------------------------------------------------------------
// Test 1: time_parse_and_diff
@@ -205,44 +205,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 5: routine_update_fail_delete_fallback
// -----------------------------------------------------------------------
#[tokio::test]
async fn routine_update_fail_delete_fallback() {
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/tools/routine_update_fail_delete_fallback.json"
))
.expect("failed to load routine_update_fail_delete_fallback.json");
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("Try converting a routine trigger, then recover by deleting it")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let completed = rig.tool_calls_completed();
assert!(
completed.iter().any(|(n, ok)| n == "routine_update" && !ok),
"routine_update should fail in this regression path: {completed:?}"
);
assert!(
completed.iter().any(|(n, ok)| n == "routine_delete" && *ok),
"routine_delete should recover successfully via preserved routine identity: {completed:?}"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// Test 6: routine_manual_create_defaults_to_tools_enabled
// Test 5: routine_manual_create_defaults_to_tools_enabled
// -----------------------------------------------------------------------
#[tokio::test]
@@ -283,7 +246,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 7: routine_manual_create_explicit_no_tools
// Test 6: routine_manual_create_explicit_no_tools
// -----------------------------------------------------------------------
#[tokio::test]
@@ -324,7 +287,7 @@ mod tests {
}
// -----------------------------------------------------------------------
// Test 8: routine_history
// Test 7: routine_history
// -----------------------------------------------------------------------
#[tokio::test]
@@ -875,4 +838,112 @@ mod tests {
rig.shutdown();
}
#[tokio::test]
async fn tool_info_clarifies_message_and_channel_setup_roles() {
let trace = LlmTrace::new(
"test-tool-info-channel-message-clarity",
vec![TraceTurn {
user_input: "How do message and channels differ?".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_tool_info_message".to_string(),
name: "tool_info".to_string(),
arguments: serde_json::json!({"name": "message"}),
}],
input_tokens: 100,
output_tokens: 20,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_tool_info_tool_search".to_string(),
name: "tool_info".to_string(),
arguments: serde_json::json!({"name": "tool_search"}),
}],
input_tokens: 140,
output_tokens: 20,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "I checked both tool descriptions.".to_string(),
input_tokens: 220,
output_tokens: 30,
},
expected_tool_results: Vec::new(),
},
],
expects: Default::default(),
}],
);
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_auto_approve_tools(true)
.build()
.await;
rig.send_message("How do message and channels differ?")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let results = rig.tool_results();
let info_results: Vec<_> = results.iter().filter(|(n, _)| n == "tool_info").collect();
assert_eq!(info_results.len(), 2, "Expected two tool_info results");
let info_json: Vec<serde_json::Value> = info_results
.iter()
.map(|(_, preview)| {
serde_json::from_str(preview)
.expect("tool_info result preview should be valid JSON")
})
.collect();
let message_json = info_json
.iter()
.find(|info| info["name"] == "message")
.expect("tool_info result should contain 'message'");
let message_description = message_json["description"]
.as_str()
.expect("message description should be a string");
assert!(
message_description.contains("Use normal assistant output to reply"),
"message description should distinguish normal replies: {message_description}"
);
assert!(
message_description.contains("proactive notifications"),
"message description should describe proactive sends: {message_description}"
);
let tool_search_json = info_json
.iter()
.find(|info| info["name"] == "tool_search")
.expect("tool_info result should contain 'tool_search'");
let tool_search_description = tool_search_json["description"]
.as_str()
.expect("tool_search description should be a string");
assert!(
tool_search_description.contains("`tool_install`")
&& tool_search_description.contains("`tool_activate`"),
"tool_search description should describe setup/activation via tool_install and \
tool_activate: {tool_search_description}"
);
assert!(
tool_search_description.contains("use the `message` tool for proactive outbound sends"),
"tool_search description should point outbound sends to message: {tool_search_description}"
);
rig.shutdown();
}
}
@@ -1,70 +0,0 @@
{
"model_name": "test-routine-update-fail-delete-fallback",
"expects": {
"tools_used": ["routine_create", "routine_update", "routine_delete"],
"tool_results_contain": {
"routine_update": "Cannot update schedule or timezone on a non-cron routine.",
"routine_delete": "temp-routine"
},
"min_responses": 1
},
"steps": [
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rc_fallback",
"name": "routine_create",
"arguments": {
"name": "temp-routine",
"trigger_type": "manual",
"prompt": "Temporary routine for fallback test."
}
}
],
"input_tokens": 120,
"output_tokens": 40
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_ru_fallback",
"name": "routine_update",
"arguments": {
"name": "temp-routine",
"schedule": "0 */10 * * * *"
}
}
],
"input_tokens": 200,
"output_tokens": 30
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_rd_fallback",
"name": "routine_delete",
"arguments": {}
}
],
"input_tokens": 300,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "I recovered from the failed update and cleaned up the original routine.",
"input_tokens": 380,
"output_tokens": 25
}
}
]
}
+43
View File
@@ -237,4 +237,47 @@ mod tests {
rig.shutdown();
}
#[tokio::test]
async fn telegram_system_prompt_clarifies_reply_vs_proactive_message_tool() {
let trace = simple_trace(1);
let rig = TestRigBuilder::new().with_trace(trace).build().await;
let msg = IncomingMessage::new("telegram", "telegram-user", "Hello there");
rig.send_incoming(msg).await;
let _responses = rig.wait_for_responses(1, TIMEOUT).await;
let requests = rig.captured_llm_requests();
let system_prompt =
extract_system_prompt(&requests).expect("Expected a system prompt in the LLM request");
assert!(
system_prompt.contains("Channels are not separate send-message tools"),
"System prompt should describe channels as setup/integration surfaces.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
system_prompt
.contains("use normal assistant output to reply in the current conversation"),
"System prompt should route ordinary replies through normal assistant output.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
system_prompt.contains("respond normally without calling `message`"),
"System prompt should say normal replies do not use the message tool.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
system_prompt.contains("proactive follow-up in the current conversation"),
"System prompt should reserve omitted channel/target for proactive follow-ups.\n\
Actual system prompt:\n{system_prompt}"
);
assert!(
!system_prompt.contains("omit 'target' to send here"),
"System prompt should not imply the message tool is the default way to reply \
in-thread.\nActual system prompt:\n{system_prompt}"
);
rig.shutdown();
}
}