fix(whatsapp): upgrade WhatsApp Web channel to upstream whatsapp-rust 0.5 (#916)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-04-26 18:41:47 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 238a9a5ad9
commit e3c46d1c3d
8 changed files with 887 additions and 1720 deletions
Generated
+403 -216
View File
@@ -69,7 +69,7 @@ dependencies = [
"aes",
"cipher",
"ctr",
"ghash",
"ghash 0.5.1",
"subtle",
]
@@ -502,6 +502,26 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bincode"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
dependencies = [
"bincode_derive",
"serde",
"unty",
]
[[package]]
name = "bincode_derive"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
dependencies = [
"virtue",
]
[[package]]
name = "bindgen"
version = "0.72.1"
@@ -1156,6 +1176,12 @@ dependencies = [
"windows 0.54.0",
]
[[package]]
name = "cpubits"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1194,24 +1220,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -1291,17 +1299,38 @@ dependencies = [
]
[[package]]
name = "dashmap"
version = "6.1.0"
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core",
"quote",
"syn 2.0.117",
]
[[package]]
@@ -1482,6 +1511,53 @@ dependencies = [
"zeroize",
]
[[package]]
name = "diesel"
version = "2.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78df0e4e8c596662edb07fbfbb7f23769cca35049827df5f909084d956b6aeaf"
dependencies = [
"diesel_derives",
"downcast-rs",
"libsqlite3-sys",
"r2d2",
"sqlite-wasm-rs",
"time",
]
[[package]]
name = "diesel_derives"
version = "2.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b79402bd1cfb25b65650f0f4901d0e79c095729e2139c8ab779d025968c7099"
dependencies = [
"diesel_table_macro_syntax",
"dsl_auto_type",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "diesel_migrations"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8"
dependencies = [
"diesel",
"migrations_internals",
"migrations_macros",
]
[[package]]
name = "diesel_table_macro_syntax"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c"
dependencies = [
"syn 2.0.117",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -1592,6 +1668,26 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
[[package]]
name = "dsl_auto_type"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e"
dependencies = [
"darling",
"either",
"heck",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "dunce"
version = "1.0.5"
@@ -1988,6 +2084,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -2230,7 +2332,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
"polyval 0.6.2",
]
[[package]]
name = "ghash"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
dependencies = [
"polyval 0.7.1",
]
[[package]]
@@ -2312,19 +2423,22 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
@@ -2818,6 +2932,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@@ -3801,6 +3921,27 @@ dependencies = [
"libc",
]
[[package]]
name = "migrations_internals"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d"
dependencies = [
"serde",
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "migrations_macros"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703"
dependencies = [
"migrations_internals",
"proc-macro2",
"quote",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -3850,26 +3991,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moka"
version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
dependencies = [
"async-lock",
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"equivalent",
"event-listener 5.4.1",
"futures-util",
"parking_lot",
"portable-atomic",
"smallvec",
"tagptr",
"uuid",
]
[[package]]
name = "moxcms"
version = "0.8.1"
@@ -4506,14 +4627,12 @@ dependencies = [
"url",
"urlencoding",
"uuid",
"wa-rs",
"wa-rs-binary",
"wa-rs-core",
"wa-rs-proto",
"wa-rs-tokio-transport",
"wa-rs-ureq-http",
"wacore",
"wait-timeout",
"webpki-roots 1.0.6",
"whatsapp-rust",
"whatsapp-rust-tokio-transport",
"whatsapp-rust-ureq-http-client",
"whisper-rs",
"xz2",
"zip",
@@ -4915,7 +5034,7 @@ checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
"universal-hash 0.5.1",
]
[[package]]
@@ -4927,7 +5046,18 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
"universal-hash 0.5.1",
]
[[package]]
name = "polyval"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede"
dependencies = [
"cpubits",
"cpufeatures 0.3.0",
"universal-hash 0.6.1",
]
[[package]]
@@ -5166,26 +5296,6 @@ dependencies = [
"prost 0.14.3",
]
[[package]]
name = "protobuf"
version = "3.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4"
dependencies = [
"once_cell",
"protobuf-support",
"thiserror 1.0.69",
]
[[package]]
name = "protobuf-support"
version = "3.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.3"
@@ -5299,6 +5409,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "r2d2"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
dependencies = [
"log",
"parking_lot",
"scheduled-thread-pool",
]
[[package]]
name = "rand"
version = "0.8.5"
@@ -5639,6 +5760,16 @@ dependencies = [
"libc",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.18",
]
[[package]]
name = "ruma"
version = "0.14.1"
@@ -5970,6 +6101,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scheduled-thread-pool"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
dependencies = [
"parking_lot",
]
[[package]]
name = "schemars"
version = "1.2.1"
@@ -6471,6 +6611,18 @@ dependencies = [
"der",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -6592,12 +6744,6 @@ dependencies = [
"windows 0.57.0",
]
[[package]]
name = "tagptr"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "tar"
version = "0.4.45"
@@ -7304,6 +7450,16 @@ dependencies = [
"subtle",
]
[[package]]
name = "universal-hash"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
dependencies = [
"crypto-common 0.2.1",
"ctutils",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
@@ -7316,6 +7472,12 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "unty"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "ureq"
version = "3.3.0"
@@ -7420,6 +7582,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "virtue"
version = "0.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
[[package]]
name = "vodozemac"
version = "0.9.0"
@@ -7451,40 +7619,53 @@ dependencies = [
]
[[package]]
name = "wa-rs"
version = "0.2.0"
name = "wacore"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fecb468bdfe1e7d4c06a1bd12908c66edaca59024862cb64757ad11c3b948b1"
checksum = "5292fb723da7505e90cfd7a828dd397802b98e0f819a2bc058a6b3a9f2345306"
dependencies = [
"aes",
"aes-gcm",
"anyhow",
"async-channel 2.5.0",
"async-lock",
"async-trait",
"base64",
"bytes",
"chrono",
"dashmap",
"env_logger",
"ctr",
"event-listener 5.4.1",
"flate2",
"futures",
"hex",
"hkdf",
"hmac 0.12.1",
"log",
"moka",
"md5",
"once_cell",
"pbkdf2",
"prost 0.14.3",
"rand 0.9.4",
"rand_core 0.10.1",
"scopeguard",
"rand 0.10.1",
"serde",
"serde-big-array",
"serde_json",
"sha1",
"sha2 0.10.9",
"thiserror 2.0.18",
"tokio",
"wa-rs-binary",
"wa-rs-core",
"wa-rs-proto",
"typed-builder",
"wacore-appstate",
"wacore-binary",
"wacore-derive",
"wacore-libsignal",
"wacore-noise",
"waproto",
]
[[package]]
name = "wa-rs-appstate"
version = "0.2.0"
name = "wacore-appstate"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3845137b3aead2d99de7c6744784bf2f5a908be9dc97a3dbd7585dc40296925c"
checksum = "ab29b1c5198e16e2619868cc3e48c32f0bcecec936fd20d09460a8f7e374604d"
dependencies = [
"anyhow",
"bytemuck",
@@ -7497,16 +7678,16 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.18",
"wa-rs-binary",
"wa-rs-libsignal",
"wa-rs-proto",
"wacore-binary",
"wacore-libsignal",
"waproto",
]
[[package]]
name = "wa-rs-binary"
version = "0.2.0"
name = "wacore-binary"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b30a6e11aebb39c07392675256ead5e2570c31382bd4835d6ddc877284b6be"
checksum = "127a3a6e7554ce002092f1711aa927b0efa3d3fb1ee83506525565c626e68834"
dependencies = [
"flate2",
"phf 0.13.1",
@@ -7516,51 +7697,10 @@ dependencies = [
]
[[package]]
name = "wa-rs-core"
version = "0.2.0"
name = "wacore-derive"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed13bb2aff2de43fc4dd821955f03ea48a1d31eda3c80efe6f905898e304d11f"
dependencies = [
"aes",
"aes-gcm",
"anyhow",
"async-channel 2.5.0",
"async-trait",
"base64",
"bytes",
"chrono",
"ctr",
"flate2",
"hex",
"hkdf",
"hmac 0.12.1",
"log",
"md5",
"once_cell",
"pbkdf2",
"prost 0.14.3",
"protobuf",
"rand 0.9.4",
"rand_core 0.10.1",
"serde",
"serde-big-array",
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.18",
"typed-builder",
"wa-rs-appstate",
"wa-rs-binary",
"wa-rs-derive",
"wa-rs-libsignal",
"wa-rs-noise",
"wa-rs-proto",
]
[[package]]
name = "wa-rs-derive"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75c03f610c9bc960e653d5d6d2a4cced9013bedbe5e6e8948787bbd418e4137c"
checksum = "3f9546a14730f112eca3bc2e4cbd815df88046fa78530f110c3665492604e770"
dependencies = [
"proc-macro2",
"quote",
@@ -7568,10 +7708,10 @@ dependencies = [
]
[[package]]
name = "wa-rs-libsignal"
version = "0.2.0"
name = "wacore-libsignal"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3471be8ff079ae4959fcddf2e7341281e5c6756bdc6a66454ea1a8e474d14576"
checksum = "c89676f7e09708d6b3a58e02bfea42000e3d7bffbc1bf67d0c8bf58ddbdf6373"
dependencies = [
"aes",
"aes-gcm",
@@ -7583,29 +7723,28 @@ dependencies = [
"curve25519-dalek",
"derive_more 2.1.1",
"displaydoc",
"ghash",
"ghash 0.6.0",
"hex",
"hkdf",
"hmac 0.12.1",
"itertools 0.14.0",
"log",
"prost 0.14.3",
"rand 0.9.4",
"rand 0.10.1",
"serde",
"sha1",
"sha2 0.10.9",
"subtle",
"thiserror 2.0.18",
"uuid",
"wa-rs-proto",
"waproto",
"x25519-dalek",
]
[[package]]
name = "wa-rs-noise"
version = "0.2.0"
name = "wacore-noise"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3efb3891c1e22ce54646dc581e34e79377dc402ed8afb11a7671c5ef629b3ae"
checksum = "3e1c5a0ef2ddaf0f7c5a227649c09de5f354571d95feea4dbac55f2b31c2da53"
dependencies = [
"aes-gcm",
"anyhow",
@@ -7613,58 +7752,12 @@ dependencies = [
"hkdf",
"log",
"prost 0.14.3",
"rand 0.9.4",
"rand_core 0.10.1",
"rand 0.10.1",
"sha2 0.10.9",
"thiserror 2.0.18",
"wa-rs-binary",
"wa-rs-libsignal",
"wa-rs-proto",
]
[[package]]
name = "wa-rs-proto"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ada50ee03752f0e66ada8cf415ed5f90d572d34039b058ce23d8b13493e510"
dependencies = [
"prost 0.14.3",
"prost-build",
"serde",
]
[[package]]
name = "wa-rs-tokio-transport"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc638c168949dc99cbb756a776869898d4ae654b36b90d5f7ce2d32bf92a404"
dependencies = [
"anyhow",
"async-channel 2.5.0",
"async-trait",
"bytes",
"futures-util",
"http 1.4.0",
"log",
"rustls",
"tokio",
"tokio-rustls",
"tokio-websockets",
"wa-rs-core",
"webpki-roots 1.0.6",
]
[[package]]
name = "wa-rs-ureq-http"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d0c7fff8a7bd93d0c17af8d797a3934144fa269fe47a615635f3bf04238806"
dependencies = [
"anyhow",
"async-trait",
"tokio",
"ureq",
"wa-rs-core",
"wacore-binary",
"wacore-libsignal",
"waproto",
]
[[package]]
@@ -7695,6 +7788,17 @@ dependencies = [
"try-lock",
]
[[package]]
name = "waproto"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fb5d942027d97e5ca1e542a9915388742cc5efab4802910ecc2fd1f430cefe"
dependencies = [
"prost 0.14.3",
"prost-build",
"serde",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -7943,6 +8047,89 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "whatsapp-rust"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b79e16847fe6e7ea8d103b4b575b1a1b77bd57cb4c292f7c50616b72a74374"
dependencies = [
"anyhow",
"async-channel 2.5.0",
"async-lock",
"async-trait",
"base64",
"bytes",
"chrono",
"env_logger",
"event-listener 5.4.1",
"futures",
"hex",
"log",
"prost 0.14.3",
"rand 0.10.1",
"scopeguard",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"wacore",
"wacore-binary",
"waproto",
"whatsapp-rust-sqlite-storage",
]
[[package]]
name = "whatsapp-rust-sqlite-storage"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce56415e95e6f22caf45ea1aec64c67d69b9d6f636a1b20fcc322dff8a787cd"
dependencies = [
"async-trait",
"bincode",
"diesel",
"diesel_migrations",
"libsqlite3-sys",
"log",
"serde_json",
"tokio",
"wacore",
"wacore-binary",
]
[[package]]
name = "whatsapp-rust-tokio-transport"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aca6e068f01d56f7d360a04aeb16d34a3edb623e7d43e7cd857682a186c0130"
dependencies = [
"anyhow",
"async-channel 2.5.0",
"async-trait",
"bytes",
"futures-util",
"http 1.4.0",
"log",
"rustls",
"tokio",
"tokio-rustls",
"tokio-websockets",
"wacore",
"webpki-roots 1.0.6",
]
[[package]]
name = "whatsapp-rust-ureq-http-client"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45ae9349f172ab8e50031ef5c429bb56ffdd677fbfeb22ed09fff3ce3b62c135"
dependencies = [
"anyhow",
"async-trait",
"tokio",
"ureq",
"wacore",
]
[[package]]
name = "whisper-rs"
version = "0.16.0"
+10 -7
View File
@@ -117,12 +117,15 @@ matrix-sdk = { version = "0.16", optional = true, default-features = false, feat
fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] }
serde-big-array = { version = "0.5", optional = true }
pdf-extract = { version = "0.10", optional = true }
wa-rs = { version = "0.2", optional = true, default-features = false }
wa-rs-core = { version = "0.2", optional = true, default-features = false }
wa-rs-binary = { version = "0.2", optional = true, default-features = false }
wa-rs-proto = { version = "0.2", optional = true, default-features = false }
wa-rs-ureq-http = { version = "0.2", optional = true }
wa-rs-tokio-transport = { version = "0.2", optional = true, default-features = false }
# WhatsApp Web — upstream `whatsapp-rust` 0.5. Replaces the previous `wa-rs`
# 0.2 fork: upstream now ships its own SqliteStore (so we no longer need the
# 1.3K-line custom RusqliteStore) and dispatches `Event::Message` for
# LID-addressed contacts and group sender-key (skmsg) messages — both of
# which the 0.2 fork silently dropped after decryption.
whatsapp-rust = { version = "0.5", optional = true, default-features = false, features = ["sqlite-storage", "tokio-runtime"] }
whatsapp-rust-tokio-transport = { version = "0.5", optional = true, default-features = false }
whatsapp-rust-ureq-http-client = { version = "0.5", optional = true }
wacore = { version = "0.5", optional = true, default-features = false }
[target.'cfg(target_os = "macos")'.dependencies]
whisper-rs = { version = "0.16", features = ["metal"] }
@@ -142,7 +145,7 @@ browser-native = ["dep:fantoccini"]
fantoccini = ["browser-native"]
landlock = ["sandbox-landlock"]
rag-pdf = ["dep:pdf-extract"]
whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-proto", "dep:wa-rs-ureq-http", "dep:wa-rs-tokio-transport", "serde-big-array"]
whatsapp-web = ["dep:whatsapp-rust", "dep:whatsapp-rust-tokio-transport", "dep:whatsapp-rust-ureq-http-client", "dep:wacore", "serde-big-array"]
# Fix whisper-rs-sys CRT mismatch on Windows MSVC (LNK2038).
# Upstream cmake build defaults to /MD but Rust uses /MT.
-2
View File
@@ -33,8 +33,6 @@ pub use providers::telegram;
pub use providers::web;
pub use providers::whatsapp;
#[cfg(feature = "whatsapp-web")]
pub use providers::whatsapp_storage;
#[cfg(feature = "whatsapp-web")]
pub use providers::whatsapp_web;
pub use cli::CliChannel;
-2
View File
@@ -18,6 +18,4 @@ pub mod telegram;
pub mod web;
pub mod whatsapp;
#[cfg(feature = "whatsapp-web")]
pub mod whatsapp_storage;
#[cfg(feature = "whatsapp-web")]
pub mod whatsapp_web;
File diff suppressed because it is too large Load Diff
@@ -1,81 +0,0 @@
use super::*;
#[cfg(feature = "whatsapp-web")]
use wa_rs_core::store::traits::{LidPnMappingEntry, ProtocolStore, TcTokenEntry};
#[cfg(feature = "whatsapp-web")]
#[test]
fn rusqlite_store_creates_database() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let store = RusqliteStore::new(tmp.path()).unwrap();
assert_eq!(store.device_id, 1);
}
#[cfg(feature = "whatsapp-web")]
#[tokio::test]
async fn lid_mapping_round_trip_preserves_learning_source_and_updated_at() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let store = RusqliteStore::new(tmp.path()).unwrap();
let entry = LidPnMappingEntry {
lid: "100000012345678".to_string(),
phone_number: "15551234567".to_string(),
created_at: 1_700_000_000,
updated_at: 1_700_000_100,
learning_source: "usync".to_string(),
};
ProtocolStore::put_lid_mapping(&store, &entry)
.await
.unwrap();
let loaded = ProtocolStore::get_lid_mapping(&store, &entry.lid)
.await
.unwrap()
.expect("expected lid mapping to be present");
assert_eq!(loaded.learning_source, entry.learning_source);
assert_eq!(loaded.updated_at, entry.updated_at);
let loaded_by_pn = ProtocolStore::get_pn_mapping(&store, &entry.phone_number)
.await
.unwrap()
.expect("expected pn mapping to be present");
assert_eq!(loaded_by_pn.learning_source, entry.learning_source);
assert_eq!(loaded_by_pn.updated_at, entry.updated_at);
}
#[cfg(feature = "whatsapp-web")]
#[tokio::test]
async fn delete_expired_tc_tokens_returns_deleted_row_count() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let store = RusqliteStore::new(tmp.path()).unwrap();
let expired = TcTokenEntry {
token: vec![1, 2, 3],
token_timestamp: 10,
sender_timestamp: None,
};
let fresh = TcTokenEntry {
token: vec![4, 5, 6],
token_timestamp: 1000,
sender_timestamp: Some(1000),
};
ProtocolStore::put_tc_token(&store, "15550000001", &expired)
.await
.unwrap();
ProtocolStore::put_tc_token(&store, "15550000002", &fresh)
.await
.unwrap();
let deleted = ProtocolStore::delete_expired_tc_tokens(&store, 100)
.await
.unwrap();
assert_eq!(deleted, 1);
assert!(ProtocolStore::get_tc_token(&store, "15550000001")
.await
.unwrap()
.is_none());
assert!(ProtocolStore::get_tc_token(&store, "15550000002")
.await
.unwrap()
.is_some());
}
+283 -148
View File
@@ -1,13 +1,19 @@
//! WhatsApp Web channel using wa-rs (native Rust implementation)
//! WhatsApp Web channel backed by upstream [`whatsapp-rust`] 0.5.
//!
//! This channel provides direct WhatsApp Web integration with:
//! - QR code and pair code linking
//! - End-to-end encryption via Signal Protocol
//! - Full Baileys parity (groups, media, presence, reactions, editing/deletion)
//! # Why the upgrade
//!
//! The previous implementation used `wa-rs` 0.2 (a fork that pinned to stable
//! Rust). That fork silently dropped `Event::Message` for LID-addressed
//! contacts and group sender-key (`skmsg`) messages: the protocol layer
//! decrypted the payload but never dispatched it to user code, breaking
//! agent dispatch for the bulk of modern WhatsApp traffic (LID is the
//! current default). Upstream `whatsapp-rust` 0.5 fixed this in PRs #170
//! (SKDM tracking) + #181 (LID/PN mapping) + sender-key dispatch, and also
//! ships its own [`SqliteStore`] — so the previous custom 1,345-line
//! `RusqliteStore` is no longer needed.
//!
//! # Feature Flag
//!
//! This channel requires the `whatsapp-web` feature flag:
//! ```sh
//! cargo build --features whatsapp-web
//! ```
@@ -17,65 +23,75 @@
//! ```toml
//! [channels.whatsapp]
//! session_path = "~/.openhuman/whatsapp-session.db" # Required for Web mode
//! pair_phone = "15551234567" # Optional: for pair code linking
//! allowed_numbers = ["+1234567890", "*"] # Same as Cloud API
//! pair_phone = "15551234567" # Optional: pair-code linking
//! allowed_numbers = ["+1234567890", "*"] # Same shape as Cloud API
//! ```
//!
//! # Runtime Negotiation
//! # Runtime negotiation
//!
//! This channel is automatically selected when `session_path` is set in the config.
//! The Cloud API channel is used when `phone_number_id` is set.
//! Selected automatically by [`crate::openhuman::channels::runtime::startup`]
//! when `session_path` is set. The Cloud API channel ([`super::whatsapp`]) is
//! used when `phone_number_id` is set instead.
//!
//! # Migration note
//!
//! The on-disk SQLite schema differs between the wa-rs 0.2 fork and the
//! upstream 0.5 store. Existing paired sessions will fail to load and will
//! prompt for a fresh QR scan on first launch after this upgrade. Pairing
//! takes about 30 seconds; the old `whatsapp-session.db` can be deleted by
//! the user afterwards.
//!
//! [`whatsapp-rust`]: https://docs.rs/whatsapp-rust/0.5
//! [`SqliteStore`]: whatsapp_rust::store::SqliteStore
use super::whatsapp_storage::RusqliteStore;
use crate::openhuman::channels::traits::{Channel, ChannelMessage, SendMessage};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use parking_lot::Mutex;
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::select;
/// WhatsApp Web channel using wa-rs with custom rusqlite storage
/// WhatsApp Web channel.
///
/// # Status: Functional Implementation
///
/// This implementation uses the wa-rs Bot with our custom RusqliteStore backend.
///
/// # Configuration
///
/// ```toml
/// [channels.whatsapp]
/// session_path = "~/.openhuman/whatsapp-session.db"
/// pair_phone = "15551234567" # Optional
/// allowed_numbers = ["+1234567890", "*"]
/// ```
/// Wraps a `whatsapp-rust` Bot with our `Channel` trait. The bot owns an
/// `Arc<Client>` for outbound operations (`send`, typing) and a `BotHandle`
/// for shutdown. Inbound messages are pushed onto an [`mpsc::Sender`] so
/// the existing channel inbound subscriber pipeline can process them.
#[cfg(feature = "whatsapp-web")]
pub struct WhatsAppWebChannel {
/// Session database path
/// Path to the SQLite session database.
session_path: String,
/// Phone number for pair code linking (optional)
/// Optional phone number for pair-code linking (E.164 digits, no leading `+`).
pair_phone: Option<String>,
/// Custom pair code (optional)
/// Optional pre-allocated pair code paired with `pair_phone`.
pair_code: Option<String>,
/// Allowed phone numbers (E.164 format) or "*" for all
/// E.164 numbers (with leading `+`) allowed to interact, or `["*"]` for any.
/// Empty also means "allow all" — same convention as the Cloud API channel.
allowed_numbers: Vec<String>,
/// Bot handle for shutdown
bot_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
/// Client handle for sending messages and typing indicators
client: Arc<Mutex<Option<Arc<wa_rs::Client>>>>,
/// Message sender channel
/// Bot run handle, retained for graceful shutdown.
bot_handle: Arc<Mutex<Option<whatsapp_rust::bot::BotHandle>>>,
/// Live client used for outbound calls; populated after `Bot::build` returns.
client: Arc<Mutex<Option<Arc<whatsapp_rust::Client>>>>,
/// Liveness signal driven by upstream `Event::Connected` / `LoggedOut` /
/// `StreamError`. Used by `health_check` so a dropped session no longer
/// reports healthy until process shutdown.
connected: Arc<AtomicBool>,
/// Group JIDs (`...@g.us`) we've already accepted an allowed inbound
/// from. Acts as outbound provenance: replies into a group are only
/// permitted after a participant on the per-number allowlist messaged
/// in. Without this, any caller able to pass a `recipient` could post
/// into arbitrary joined groups via the @g.us suffix.
allowed_groups: Arc<Mutex<HashSet<String>>>,
/// Sink for inbound `ChannelMessage`s. Populated when [`Channel::listen`]
/// is called and shared with the event-handler closure.
tx: Arc<Mutex<Option<tokio::sync::mpsc::Sender<ChannelMessage>>>>,
}
#[cfg(feature = "whatsapp-web")]
impl WhatsAppWebChannel {
/// Create a new WhatsApp Web channel
///
/// # Arguments
///
/// * `session_path` - Path to the SQLite session database
/// * `pair_phone` - Optional phone number for pair code linking (format: "15551234567")
/// * `pair_code` - Optional custom pair code (leave empty for auto-generated)
/// * `allowed_numbers` - Phone numbers allowed to interact (E.164 format) or "*" for all
#[cfg(feature = "whatsapp-web")]
/// Construct a channel. The bot does not connect until [`Channel::listen`]
/// is invoked.
pub fn new(
session_path: String,
pair_phone: Option<String>,
@@ -89,19 +105,88 @@ impl WhatsAppWebChannel {
allowed_numbers,
bot_handle: Arc::new(Mutex::new(None)),
client: Arc::new(Mutex::new(None)),
connected: Arc::new(AtomicBool::new(false)),
allowed_groups: Arc::new(Mutex::new(HashSet::new())),
tx: Arc::new(Mutex::new(None)),
}
}
/// Check if a phone number is allowed (E.164 format: +1234567890)
#[cfg(feature = "whatsapp-web")]
/// Allowlist check. Empty list ⇒ allow-all (matches Cloud API behaviour).
fn is_number_allowed(&self, phone: &str) -> bool {
self.allowed_numbers.is_empty()
|| self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
}
/// Normalize phone number to E.164 format
#[cfg(feature = "whatsapp-web")]
/// Recognise WhatsApp group JIDs (`...@g.us`). Group recipients bypass
/// the per-number outbound allowlist because group membership is
/// governed by WhatsApp itself; the inbound side already gated on the
/// participant's allowlist status before we ever decided to reply.
fn is_group_jid(recipient: &str) -> bool {
recipient.trim().ends_with("@g.us")
}
/// Outbound gate combining group-provenance with the per-number allowlist.
/// Group JIDs are only permitted when an allowed inbound has already
/// been received from that exact group — populated in the inbound
/// handler when an allow-listed participant posts. This narrows the
/// previous "all `@g.us` is fine" path so an attacker that can supply
/// a `recipient` cannot post into arbitrary groups the bot has joined.
fn should_allow_outbound(&self, recipient: &str) -> bool {
if Self::is_group_jid(recipient) {
return self.allowed_groups.lock().contains(recipient.trim());
}
let normalized = self.normalize_phone(recipient);
self.is_number_allowed(&normalized)
}
/// Mask a recipient identifier for log emission. Handles bare phone
/// numbers, `<digits>@s.whatsapp.net`/`@lid` DM JIDs, and `@g.us`
/// group JIDs uniformly so warning paths never carry a full ID.
fn redact_recipient(recipient: &str) -> String {
let trimmed = recipient.trim();
if let Some((user, server)) = trimmed.split_once('@') {
format!("{}@{}", Self::redact_phone(user), server)
} else {
Self::redact_phone(trimmed)
}
}
/// Pick the address downstream replies should be sent back to.
///
/// Group chats are addressed by the group JID (`...@g.us`); a reply that
/// targeted the participant's phone instead would leak the conversation
/// into a private DM.
fn compute_reply_target(chat_jid: &str, sender_normalized: &str) -> String {
if chat_jid.ends_with("@g.us") {
chat_jid.to_string()
} else {
sender_normalized.to_string()
}
}
/// Mask the middle digits of an E.164 number so logs only carry a coarse
/// fingerprint instead of the full identifier.
fn redact_phone(phone: &str) -> String {
let prefix = if phone.starts_with('+') { "+" } else { "" };
if phone.len() <= prefix.len() + 4 {
return format!("{prefix}****");
}
let tail = &phone[phone.len() - 4..];
format!("{prefix}***{tail}")
}
/// Pull the displayable text out of an inbound WhatsApp Message proto.
/// Falls back from `conversation` to `extended_text_message.text`, then
/// to an empty string for non-text payloads.
fn extract_message_text(conversation: Option<&str>, extended_text: Option<&str>) -> String {
conversation
.or(extended_text)
.map(|s| s.to_string())
.unwrap_or_default()
}
/// Render an arbitrary recipient string as E.164 with a leading `+`,
/// stripping any `@server` JID suffix the caller passed in.
fn normalize_phone(&self, phone: &str) -> String {
let trimmed = phone.trim();
let user_part = trimmed
@@ -109,20 +194,12 @@ impl WhatsAppWebChannel {
.map(|(user, _)| user)
.unwrap_or(trimmed);
let normalized_user = user_part.trim_start_matches('+');
if user_part.starts_with('+') {
format!("+{normalized_user}")
} else {
format!("+{normalized_user}")
}
format!("+{normalized_user}")
}
/// Convert a recipient to a wa-rs JID.
///
/// Supports:
/// - Full JIDs (e.g. "12345@s.whatsapp.net")
/// - E.164-like numbers (e.g. "+1234567890")
#[cfg(feature = "whatsapp-web")]
fn recipient_to_jid(&self, recipient: &str) -> Result<wa_rs_binary::jid::Jid> {
/// Convert a recipient (full JID like `12345@s.whatsapp.net` or an E.164
/// number like `+1234567890`) into a `whatsapp-rust` JID.
fn recipient_to_jid(&self, recipient: &str) -> Result<whatsapp_rust::Jid> {
let trimmed = recipient.trim();
if trimmed.is_empty() {
anyhow::bail!("Recipient cannot be empty");
@@ -130,7 +207,7 @@ impl WhatsAppWebChannel {
if trimmed.contains('@') {
return trimmed
.parse::<wa_rs_binary::jid::Jid>()
.parse::<whatsapp_rust::Jid>()
.map_err(|e| anyhow!("Invalid WhatsApp JID `{trimmed}`: {e}"));
}
@@ -139,7 +216,7 @@ impl WhatsAppWebChannel {
anyhow::bail!("Recipient `{trimmed}` does not contain a valid phone number");
}
Ok(wa_rs_binary::jid::Jid::pn(digits))
Ok(whatsapp_rust::Jid::pn(digits))
}
}
@@ -156,18 +233,16 @@ impl Channel for WhatsAppWebChannel {
anyhow::bail!("WhatsApp Web client not connected. Initialize the bot first.");
};
// Validate recipient is allowed
let normalized = self.normalize_phone(&message.recipient);
if !self.is_number_allowed(&normalized) {
if !self.should_allow_outbound(&message.recipient) {
tracing::warn!(
"WhatsApp Web: recipient {} not in allowed list",
message.recipient
Self::redact_recipient(&message.recipient)
);
return Ok(());
}
let to = self.recipient_to_jid(&message.recipient)?;
let outgoing = wa_rs_proto::whatsapp::Message {
let outgoing = whatsapp_rust::waproto::whatsapp::Message {
conversation: Some(message.content.clone()),
..Default::default()
};
@@ -182,124 +257,178 @@ impl Channel for WhatsAppWebChannel {
}
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
// Store the sender channel for incoming messages
*self.tx.lock() = Some(tx.clone());
use wa_rs::bot::Bot;
use wa_rs::pair_code::PairCodeOptions;
use wa_rs::store::{Device, DeviceStore};
use wa_rs_binary::jid::JidExt as _;
use wa_rs_core::proto_helpers::MessageExt;
use wa_rs_core::types::events::Event;
use wa_rs_tokio_transport::TokioWebSocketTransportFactory;
use wa_rs_ureq_http::UreqHttpClient;
use wacore::types::events::Event;
use whatsapp_rust::bot::Bot;
use whatsapp_rust::pair_code::PairCodeOptions;
use whatsapp_rust::store::SqliteStore;
use whatsapp_rust::TokioRuntime;
use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory;
use whatsapp_rust_ureq_http_client::UreqHttpClient;
tracing::info!(
"WhatsApp Web channel starting (session: {})",
self.session_path
);
// Initialize storage backend
let storage = RusqliteStore::new(&self.session_path)?;
let backend = Arc::new(storage);
// Upstream's SqliteStore implements all four storage traits the bot
// needs (Signal, AppSync, Protocol, Device). It also handles
// first-run schema creation, so no separate `exists`/`load` dance.
// If the on-disk DB is a leftover from the wa-rs 0.2 fork the schema
// is incompatible — surface that explicitly so the user knows to
// delete the old session file and re-pair.
let backend = Arc::new(SqliteStore::new(&self.session_path).await.map_err(|e| {
anyhow!(
"Failed to open WhatsApp session store at `{}`: {}. \
If upgrading from wa-rs 0.2, delete the old DB and re-pair \
(see the module-level migration note).",
self.session_path,
e
)
})?);
// Check if we have a saved device to load
let mut device = Device::new(backend.clone());
if backend.exists().await? {
tracing::info!("WhatsApp Web: found existing session, loading device");
if let Some(core_device) = backend.load().await? {
device.load_from_serializable(core_device);
} else {
anyhow::bail!("Device exists but failed to load");
}
} else {
tracing::info!(
"WhatsApp Web: no existing session, new device will be created during pairing"
);
};
// Create transport factory
let mut transport_factory = TokioWebSocketTransportFactory::new();
if let Ok(ws_url) = std::env::var("WHATSAPP_WS_URL") {
transport_factory = transport_factory.with_url(ws_url);
}
// Create HTTP client for media operations
let http_client = UreqHttpClient::new();
// Build the bot
let tx_clone = tx.clone();
let tx_for_handler = tx.clone();
let allowed_numbers = self.allowed_numbers.clone();
let connected_for_handler = Arc::clone(&self.connected);
let allowed_groups_for_handler = Arc::clone(&self.allowed_groups);
let mut builder = Bot::builder()
.with_backend(backend)
.with_transport_factory(transport_factory)
.with_http_client(http_client)
.with_runtime(TokioRuntime)
.on_event(move |event, _client| {
let tx_inner = tx_clone.clone();
let tx_inner = tx_for_handler.clone();
let allowed_numbers = allowed_numbers.clone();
let connected = Arc::clone(&connected_for_handler);
let allowed_groups = Arc::clone(&allowed_groups_for_handler);
async move {
match event {
Event::Message(msg, info) => {
// Extract message content
let text = msg.text_content().unwrap_or("");
let sender = info.source.sender.user().to_string();
let chat = info.source.chat.to_string();
// Self-echoes (messages this user sent from another
// linked device) are mirrored to all devices via
// the WhatsApp protocol. Drop them so the agent
// doesn't react to its own outgoing messages.
if info.source.is_from_me {
return;
}
tracing::info!("📨 WhatsApp message from {} in {}: {}", sender, chat, text);
let text = Self::extract_message_text(
msg.conversation.as_deref(),
msg.extended_text_message
.as_ref()
.and_then(|e| e.text.as_deref()),
);
// Check if sender is allowed
let normalized = if sender.starts_with('+') {
sender.clone()
// Sender JID can use either the legacy `s.whatsapp.net`
// server (phone-number addressing) or the newer `lid`
// server (privacy-preserving identifier). Render the
// user portion in E.164 with a leading `+` for the
// allowed-list check + downstream subscriber.
let sender_user = info.source.sender.user.clone();
let normalized = if sender_user.starts_with('+') {
sender_user.clone()
} else {
format!("+{sender}")
format!("+{sender_user}")
};
let chat = info.source.chat.to_string();
let reply_target = Self::compute_reply_target(&chat, &normalized);
// Routine logs only carry coarse metadata — no raw
// sender identifier, no message body — so PII does
// not leak into application logs at any level.
// For DM chats `chat` is `<phone>@s.whatsapp.net`,
// which still carries the participant's phone
// number. Redact the user part so the routine
// log keeps only the server suffix (DM vs group)
// and a coarse identifier tail.
tracing::info!(
"📨 WhatsApp inbound: chat={} sender={} text_len={}",
Self::redact_recipient(&chat),
Self::redact_phone(&normalized),
text.len()
);
if allowed_numbers.is_empty()
|| allowed_numbers.iter().any(|n| n == "*" || n == &normalized)
{
// Record group provenance: this group has had at
// least one allow-listed participant message in,
// so subsequent outbound replies into the same
// group are legitimate. Outbound to groups
// without provenance is rejected by
// `should_allow_outbound`.
if Self::is_group_jid(&chat) {
allowed_groups.lock().insert(chat.clone());
}
if let Err(e) = tx_inner
.send(ChannelMessage {
id: uuid::Uuid::new_v4().to_string(),
channel: "whatsapp".to_string(),
sender: normalized.clone(),
reply_target: normalized.clone(),
content: text.to_string(),
reply_target,
content: text,
timestamp: chrono::Utc::now().timestamp_millis() as u64,
thread_ts: None,
})
.await
{
tracing::error!("Failed to send message to channel: {}", e);
tracing::error!(
"Failed to forward WhatsApp message to channel: {}",
e
);
}
} else {
tracing::warn!("WhatsApp Web: message from {} not in allowed list", normalized);
tracing::warn!(
"WhatsApp Web: message from {} not in allowed list",
Self::redact_phone(&normalized)
);
}
}
Event::Connected(_) => {
connected.store(true, Ordering::Release);
tracing::info!("✅ WhatsApp Web connected successfully!");
}
Event::LoggedOut(_) => {
connected.store(false, Ordering::Release);
tracing::warn!("❌ WhatsApp Web was logged out!");
}
Event::StreamError(stream_error) => {
connected.store(false, Ordering::Release);
tracing::error!("❌ WhatsApp Web stream error: {:?}", stream_error);
}
Event::PairingCode { code, .. } => {
tracing::info!("🔑 Pair code received: {}", code);
tracing::info!("Link your phone by entering this code in WhatsApp > Linked Devices");
// The pair code and QR payload are short-lived link
// credentials — anyone reading the logs while they
// are valid can hijack the session. Surface only a
// non-sensitive notice; the raw payload is never
// logged at any level. Surfacing the code to the
// user is the responsibility of an upstream UX
// path (e.g. a JSON-RPC event the frontend renders).
Event::PairingCode { .. } => {
tracing::info!(
"🔑 WhatsApp Web pair code received. Enter the code shown on \
your linking surface into WhatsApp > Linked Devices."
);
}
Event::PairingQrCode { code, .. } => {
tracing::info!("📱 QR code received (scan with WhatsApp > Linked Devices)");
tracing::debug!("QR code: {}", code);
Event::PairingQrCode { .. } => {
tracing::info!(
"📱 WhatsApp Web QR code received. Render via QR generator and \
scan with WhatsApp > Linked Devices."
);
}
_ => {}
}
}
})
;
});
// Configure pair-code flow when a phone number is provided.
if let Some(ref phone) = self.pair_phone {
tracing::info!("WhatsApp Web: pair-code flow enabled for configured phone number");
builder = builder.with_pair_code(PairCodeOptions {
@@ -316,35 +445,43 @@ impl Channel for WhatsAppWebChannel {
let mut bot = builder.build().await?;
*self.client.lock() = Some(bot.client());
// Run the bot
let bot_handle = bot.run().await?;
// Store the bot handle for later shutdown
*self.bot_handle.lock() = Some(bot_handle);
// Wait for shutdown signal
let (_shutdown_tx, mut shutdown_rx) = tokio::sync::broadcast::channel::<()>(1);
select! {
_ = shutdown_rx.recv() => {
tracing::info!("WhatsApp Web channel shutting down");
// Wire into the shared shutdown machinery in `core::shutdown` so
// SIGTERM and SIGINT both trigger a coordinated tear-down. The
// previous `tokio::signal::ctrl_c()` path silently ignored
// SIGTERM and bypassed the registered cleanup hooks the rest of
// the process uses.
let shutdown_notify = Arc::new(tokio::sync::Notify::new());
let bot_handle_for_hook = Arc::clone(&self.bot_handle);
let connected_for_hook = Arc::clone(&self.connected);
let client_for_hook = Arc::clone(&self.client);
let notify_for_hook = Arc::clone(&shutdown_notify);
crate::core::shutdown::register(move || {
let bot_handle = Arc::clone(&bot_handle_for_hook);
let connected = Arc::clone(&connected_for_hook);
let client = Arc::clone(&client_for_hook);
let notify = Arc::clone(&notify_for_hook);
async move {
tracing::info!("[whatsapp_web] graceful shutdown hook firing — aborting bot");
connected.store(false, Ordering::Release);
*client.lock() = None;
if let Some(handle) = bot_handle.lock().take() {
handle.abort();
}
notify.notify_waiters();
}
_ = tokio::signal::ctrl_c() => {
tracing::info!("WhatsApp Web channel received Ctrl+C");
}
}
});
*self.client.lock() = None;
if let Some(handle) = self.bot_handle.lock().take() {
handle.abort();
}
shutdown_notify.notified().await;
tracing::info!("WhatsApp Web channel exited via shared shutdown");
Ok(())
}
async fn health_check(&self) -> bool {
let bot_handle_guard = self.bot_handle.lock();
bot_handle_guard.is_some()
self.connected.load(Ordering::Acquire)
}
async fn start_typing(&self, recipient: &str) -> Result<()> {
@@ -353,11 +490,10 @@ impl Channel for WhatsAppWebChannel {
anyhow::bail!("WhatsApp Web client not connected. Initialize the bot first.");
};
let normalized = self.normalize_phone(recipient);
if !self.is_number_allowed(&normalized) {
if !self.should_allow_outbound(recipient) {
tracing::warn!(
"WhatsApp Web: typing target {} not in allowed list",
recipient
Self::redact_recipient(recipient)
);
return Ok(());
}
@@ -379,11 +515,10 @@ impl Channel for WhatsAppWebChannel {
anyhow::bail!("WhatsApp Web client not connected. Initialize the bot first.");
};
let normalized = self.normalize_phone(recipient);
if !self.is_number_allowed(&normalized) {
if !self.should_allow_outbound(recipient) {
tracing::warn!(
"WhatsApp Web: typing target {} not in allowed list",
recipient
Self::redact_recipient(recipient)
);
return Ok(());
}
@@ -400,7 +535,8 @@ impl Channel for WhatsAppWebChannel {
}
}
// Stub implementation when feature is not enabled
// Stub implementation when the feature is not enabled. Keeps the public ctor
// signature compatible so `runtime/startup.rs` compiles unchanged.
#[cfg(not(feature = "whatsapp-web"))]
pub struct WhatsAppWebChannel {
_private: (),
@@ -458,6 +594,5 @@ impl Channel for WhatsAppWebChannel {
}
}
#[cfg(test)]
#[path = "whatsapp_web_tests.rs"]
mod tests;
@@ -61,3 +61,194 @@ async fn whatsapp_web_health_check_disconnected() {
let ch = make_channel();
assert!(!ch.health_check().await);
}
#[tokio::test]
#[cfg(feature = "whatsapp-web")]
async fn whatsapp_web_health_check_tracks_connected_flag() {
let ch = make_channel();
assert!(!ch.health_check().await);
ch.connected.store(true, Ordering::Release);
assert!(ch.health_check().await);
ch.connected.store(false, Ordering::Release);
assert!(!ch.health_check().await);
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_compute_reply_target_dm_pn() {
assert_eq!(
WhatsAppWebChannel::compute_reply_target("123@s.whatsapp.net", "+1234567890"),
"+1234567890"
);
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_compute_reply_target_dm_lid() {
assert_eq!(
WhatsAppWebChannel::compute_reply_target("abc@lid", "+1234567890"),
"+1234567890"
);
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_compute_reply_target_group() {
assert_eq!(
WhatsAppWebChannel::compute_reply_target("987654@g.us", "+1234567890"),
"987654@g.us"
);
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_redact_phone_e164() {
assert_eq!(WhatsAppWebChannel::redact_phone("+1234567890"), "+***7890");
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_redact_phone_no_plus() {
assert_eq!(WhatsAppWebChannel::redact_phone("1234567890"), "***7890");
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_redact_phone_short_input() {
// Pathological short inputs collapse to a generic mask rather than
// exposing the entire identifier.
assert_eq!(WhatsAppWebChannel::redact_phone("+12"), "+****");
assert_eq!(WhatsAppWebChannel::redact_phone("12"), "****");
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_extract_message_text_prefers_conversation() {
assert_eq!(
WhatsAppWebChannel::extract_message_text(Some("hello"), Some("ignored")),
"hello"
);
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_extract_message_text_falls_back_to_extended() {
assert_eq!(
WhatsAppWebChannel::extract_message_text(None, Some("from extended")),
"from extended"
);
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_extract_message_text_empty_when_missing() {
assert_eq!(WhatsAppWebChannel::extract_message_text(None, None), "");
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_is_group_jid_recognises_group() {
assert!(WhatsAppWebChannel::is_group_jid("123456@g.us"));
assert!(WhatsAppWebChannel::is_group_jid(" 4567@g.us "));
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_is_group_jid_rejects_non_group() {
assert!(!WhatsAppWebChannel::is_group_jid("+1234567890"));
assert!(!WhatsAppWebChannel::is_group_jid("123@s.whatsapp.net"));
assert!(!WhatsAppWebChannel::is_group_jid("abc@lid"));
assert!(!WhatsAppWebChannel::is_group_jid(""));
}
/// Regression for CodeRabbit finding: an `@g.us` reply target was being
/// silently dropped because the outbound path normalised the JID to
/// `+<group-id>` and missed the per-number allowlist. After provenance
/// is recorded, an allowed user replying back into the group they came
/// from must succeed.
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_should_allow_outbound_provenanced_group_allowed() {
let ch = make_channel(); // allowed_numbers = ["+1234567890"]
ch.allowed_groups
.lock()
.insert("987654321@g.us".to_string());
assert!(ch.should_allow_outbound("987654321@g.us"));
}
/// Regression for the follow-up CodeRabbit finding: a blanket `@g.us`
/// bypass is itself a vulnerability — a caller able to set `recipient`
/// could post into arbitrary joined groups. Groups without recorded
/// provenance must stay blocked.
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_should_allow_outbound_unrelated_group_blocked() {
let ch = make_channel();
ch.allowed_groups
.lock()
.insert("987654321@g.us".to_string());
assert!(!ch.should_allow_outbound("11111@g.us"));
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_should_allow_outbound_group_without_provenance_blocked() {
let ch = make_channel();
// empty allowed_groups
assert!(!ch.should_allow_outbound("987654321@g.us"));
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_redact_recipient_pn_jid() {
assert_eq!(
WhatsAppWebChannel::redact_recipient("1234567890@s.whatsapp.net"),
"***7890@s.whatsapp.net"
);
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_redact_recipient_group_jid() {
assert_eq!(
WhatsAppWebChannel::redact_recipient("987654321@g.us"),
"***4321@g.us"
);
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_redact_recipient_bare_phone() {
assert_eq!(
WhatsAppWebChannel::redact_recipient("+1234567890"),
"+***7890"
);
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_should_allow_outbound_dm_blocks_unallowed() {
let ch = make_channel();
assert!(!ch.should_allow_outbound("+9999999999"));
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_should_allow_outbound_dm_allows_match() {
let ch = make_channel();
assert!(ch.should_allow_outbound("+1234567890"));
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_should_allow_outbound_wildcard_passes_dm() {
let ch = WhatsAppWebChannel::new("/tmp/t.db".into(), None, None, vec!["*".into()]);
assert!(ch.should_allow_outbound("+9999999999"));
}
#[test]
#[cfg(feature = "whatsapp-web")]
fn whatsapp_web_should_allow_outbound_empty_allowlist_passes_dm() {
let ch = WhatsAppWebChannel::new("/tmp/t.db".into(), None, None, vec![]);
assert!(ch.should_allow_outbound("+9999999999"));
}