From e3c46d1c3dc3d3659b43ad5af34c74f8ac79c037 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Mon, 27 Apr 2026 07:11:47 +0530 Subject: [PATCH] fix(whatsapp): upgrade WhatsApp Web channel to upstream whatsapp-rust 0.5 (#916) Co-authored-by: Claude Opus 4.7 (1M context) --- Cargo.lock | 619 +++++--- Cargo.toml | 17 +- src/openhuman/channels/mod.rs | 2 - src/openhuman/channels/providers/mod.rs | 2 - .../channels/providers/whatsapp_storage.rs | 1264 ----------------- .../providers/whatsapp_storage_tests.rs | 81 -- .../channels/providers/whatsapp_web.rs | 431 ++++-- .../channels/providers/whatsapp_web_tests.rs | 191 +++ 8 files changed, 887 insertions(+), 1720 deletions(-) delete mode 100644 src/openhuman/channels/providers/whatsapp_storage.rs delete mode 100644 src/openhuman/channels/providers/whatsapp_storage_tests.rs diff --git a/Cargo.lock b/Cargo.lock index d400aa2d3..e11f792f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 38b113570..323ab9cfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/src/openhuman/channels/mod.rs b/src/openhuman/channels/mod.rs index d4dfc1cc3..210154297 100644 --- a/src/openhuman/channels/mod.rs +++ b/src/openhuman/channels/mod.rs @@ -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; diff --git a/src/openhuman/channels/providers/mod.rs b/src/openhuman/channels/providers/mod.rs index 057dd21ff..34bae4714 100644 --- a/src/openhuman/channels/providers/mod.rs +++ b/src/openhuman/channels/providers/mod.rs @@ -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; diff --git a/src/openhuman/channels/providers/whatsapp_storage.rs b/src/openhuman/channels/providers/whatsapp_storage.rs deleted file mode 100644 index 7c1bb7efb..000000000 --- a/src/openhuman/channels/providers/whatsapp_storage.rs +++ /dev/null @@ -1,1264 +0,0 @@ -//! Custom wa-rs storage backend using OpenHuman's rusqlite -//! -//! This module implements all 4 wa-rs storage traits using rusqlite directly, -//! avoiding the Diesel/libsqlite3-sys dependency conflict from wa-rs-sqlite-storage. -//! -//! # Traits Implemented -//! -//! - [`SignalStore`]: Signal protocol cryptographic operations -//! - [`AppSyncStore`]: WhatsApp app state synchronization -//! - [`ProtocolStore`]: WhatsApp Web protocol alignment -//! - [`DeviceStore`]: Device persistence operations - -#[cfg(feature = "whatsapp-web")] -use async_trait::async_trait; -#[cfg(feature = "whatsapp-web")] -use parking_lot::Mutex; -#[cfg(feature = "whatsapp-web")] -use rusqlite::{params, Connection}; -#[cfg(feature = "whatsapp-web")] -use std::path::Path; -#[cfg(feature = "whatsapp-web")] -use std::sync::Arc; - -#[cfg(feature = "whatsapp-web")] -use prost::Message; -#[cfg(feature = "whatsapp-web")] -use wa_rs_binary::jid::Jid; -#[cfg(feature = "whatsapp-web")] -use wa_rs_core::appstate::hash::HashState; -#[cfg(feature = "whatsapp-web")] -use wa_rs_core::appstate::processor::AppStateMutationMAC; -#[cfg(feature = "whatsapp-web")] -use wa_rs_core::store::traits::DeviceInfo; -#[cfg(feature = "whatsapp-web")] -use wa_rs_core::store::traits::DeviceStore as DeviceStoreTrait; -#[cfg(feature = "whatsapp-web")] -use wa_rs_core::store::traits::*; -#[cfg(feature = "whatsapp-web")] -use wa_rs_core::store::Device as CoreDevice; - -/// Custom wa-rs storage backend using rusqlite -/// -/// This implements all 4 storage traits required by wa-rs. -/// The backend uses OpenHuman's existing rusqlite setup, avoiding the -/// Diesel/libsqlite3-sys conflict from wa-rs-sqlite-storage. -#[cfg(feature = "whatsapp-web")] -#[derive(Clone)] -pub struct RusqliteStore { - /// Database file path - db_path: String, - /// SQLite connection (thread-safe via Mutex) - conn: Arc>, - /// Device ID for this session - device_id: i32, -} - -/// Helper macro to convert rusqlite errors to StoreError -/// For execute statements that return usize, maps to () -macro_rules! to_store_err { - // For expressions returning Result - (execute: $expr:expr) => { - $expr - .map(|_| ()) - .map_err(|e| wa_rs_core::store::error::StoreError::Database(e.to_string())) - }; - // For other expressions - ($expr:expr) => { - $expr.map_err(|e| wa_rs_core::store::error::StoreError::Database(e.to_string())) - }; -} - -#[cfg(feature = "whatsapp-web")] -impl RusqliteStore { - /// Create a new rusqlite-based storage backend - /// - /// # Arguments - /// - /// * `db_path` - Path to the SQLite database file (will be created if needed) - pub fn new>(db_path: P) -> anyhow::Result { - let db_path = db_path.as_ref().to_string_lossy().to_string(); - - // Create parent directory if needed - if let Some(parent) = Path::new(&db_path).parent() { - std::fs::create_dir_all(parent)?; - } - - let conn = Connection::open(&db_path)?; - - // Enable WAL mode for better concurrency - to_store_err!(conn.execute_batch( - "PRAGMA journal_mode = WAL; - PRAGMA synchronous = NORMAL;", - ))?; - - let store = Self { - db_path, - conn: Arc::new(Mutex::new(conn)), - device_id: 1, // Default device ID - }; - - store.init_schema()?; - - Ok(store) - } - - /// Initialize all database tables - fn init_schema(&self) -> anyhow::Result<()> { - let conn = self.conn.lock(); - to_store_err!(conn.execute_batch( - "-- Main device table - CREATE TABLE IF NOT EXISTS device ( - id INTEGER PRIMARY KEY, - lid TEXT, - pn TEXT, - registration_id INTEGER NOT NULL, - noise_key BLOB NOT NULL, - identity_key BLOB NOT NULL, - signed_pre_key BLOB NOT NULL, - signed_pre_key_id INTEGER NOT NULL, - signed_pre_key_signature BLOB NOT NULL, - adv_secret_key BLOB NOT NULL, - account BLOB, - push_name TEXT NOT NULL, - app_version_primary INTEGER NOT NULL, - app_version_secondary INTEGER NOT NULL, - app_version_tertiary INTEGER NOT NULL, - app_version_last_fetched_ms INTEGER NOT NULL, - edge_routing_info BLOB, - props_hash TEXT - ); - - -- Signal identity keys - CREATE TABLE IF NOT EXISTS identities ( - address TEXT NOT NULL, - key BLOB NOT NULL, - device_id INTEGER NOT NULL, - PRIMARY KEY (address, device_id) - ); - - -- Signal protocol sessions - CREATE TABLE IF NOT EXISTS sessions ( - address TEXT NOT NULL, - record BLOB NOT NULL, - device_id INTEGER NOT NULL, - PRIMARY KEY (address, device_id) - ); - - -- Pre-keys for key exchange - CREATE TABLE IF NOT EXISTS prekeys ( - id INTEGER NOT NULL, - key BLOB NOT NULL, - uploaded INTEGER NOT NULL DEFAULT 0, - device_id INTEGER NOT NULL, - PRIMARY KEY (id, device_id) - ); - - -- Signed pre-keys - CREATE TABLE IF NOT EXISTS signed_prekeys ( - id INTEGER NOT NULL, - record BLOB NOT NULL, - device_id INTEGER NOT NULL, - PRIMARY KEY (id, device_id) - ); - - -- Sender keys for group messaging - CREATE TABLE IF NOT EXISTS sender_keys ( - address TEXT NOT NULL, - record BLOB NOT NULL, - device_id INTEGER NOT NULL, - PRIMARY KEY (address, device_id) - ); - - -- App state sync keys - CREATE TABLE IF NOT EXISTS app_state_keys ( - key_id BLOB NOT NULL, - key_data BLOB NOT NULL, - device_id INTEGER NOT NULL, - PRIMARY KEY (key_id, device_id) - ); - - -- App state versions - CREATE TABLE IF NOT EXISTS app_state_versions ( - name TEXT NOT NULL, - state_data BLOB NOT NULL, - device_id INTEGER NOT NULL, - PRIMARY KEY (name, device_id) - ); - - -- App state mutation MACs - CREATE TABLE IF NOT EXISTS app_state_mutation_macs ( - name TEXT NOT NULL, - version INTEGER NOT NULL, - index_mac BLOB NOT NULL, - value_mac BLOB NOT NULL, - device_id INTEGER NOT NULL, - PRIMARY KEY (name, index_mac, device_id) - ); - - -- LID to phone number mapping - CREATE TABLE IF NOT EXISTS lid_pn_mapping ( - lid TEXT NOT NULL, - phone_number TEXT NOT NULL, - created_at INTEGER NOT NULL, - learning_source TEXT NOT NULL, - updated_at INTEGER NOT NULL, - device_id INTEGER NOT NULL, - PRIMARY KEY (lid, device_id) - ); - - -- SKDM recipients tracking - CREATE TABLE IF NOT EXISTS skdm_recipients ( - group_jid TEXT NOT NULL, - device_jid TEXT NOT NULL, - device_id INTEGER NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (group_jid, device_jid, device_id) - ); - - -- Device registry for multi-device - CREATE TABLE IF NOT EXISTS device_registry ( - user_id TEXT NOT NULL, - devices_json TEXT NOT NULL, - timestamp INTEGER NOT NULL, - phash TEXT, - device_id INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (user_id, device_id) - ); - - -- Base keys for collision detection - CREATE TABLE IF NOT EXISTS base_keys ( - address TEXT NOT NULL, - message_id TEXT NOT NULL, - base_key BLOB NOT NULL, - device_id INTEGER NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (address, message_id, device_id) - ); - - -- Sender key status for lazy deletion - CREATE TABLE IF NOT EXISTS sender_key_status ( - group_jid TEXT NOT NULL, - participant TEXT NOT NULL, - device_id INTEGER NOT NULL, - marked_at INTEGER NOT NULL, - PRIMARY KEY (group_jid, participant, device_id) - ); - - -- Trusted contact tokens - CREATE TABLE IF NOT EXISTS tc_tokens ( - jid TEXT NOT NULL, - token BLOB NOT NULL, - token_timestamp INTEGER NOT NULL, - sender_timestamp INTEGER, - device_id INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (jid, device_id) - );", - ))?; - Ok(()) - } -} - -#[cfg(feature = "whatsapp-web")] -#[async_trait] -impl SignalStore for RusqliteStore { - // --- Identity Operations --- - - async fn put_identity( - &self, - address: &str, - key: [u8; 32], - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO identities (address, key, device_id) - VALUES (?1, ?2, ?3)", - params![address, key.to_vec(), self.device_id], - )) - } - - async fn load_identity( - &self, - address: &str, - ) -> wa_rs_core::store::error::Result>> { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT key FROM identities WHERE address = ?1 AND device_id = ?2", - params![address, self.device_id], - |row| row.get::<_, Vec>(0), - ); - - match result { - Ok(key) => Ok(Some(key)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn delete_identity(&self, address: &str) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "DELETE FROM identities WHERE address = ?1 AND device_id = ?2", - params![address, self.device_id], - )) - } - - // --- Session Operations --- - - async fn get_session( - &self, - address: &str, - ) -> wa_rs_core::store::error::Result>> { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT record FROM sessions WHERE address = ?1 AND device_id = ?2", - params![address, self.device_id], - |row| row.get::<_, Vec>(0), - ); - - match result { - Ok(record) => Ok(Some(record)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn put_session( - &self, - address: &str, - session: &[u8], - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO sessions (address, record, device_id) - VALUES (?1, ?2, ?3)", - params![address, session, self.device_id], - )) - } - - async fn delete_session(&self, address: &str) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "DELETE FROM sessions WHERE address = ?1 AND device_id = ?2", - params![address, self.device_id], - )) - } - - // --- PreKey Operations --- - - async fn store_prekey( - &self, - id: u32, - record: &[u8], - uploaded: bool, - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO prekeys (id, key, uploaded, device_id) - VALUES (?1, ?2, ?3, ?4)", - params![id, record, uploaded, self.device_id], - )) - } - - async fn load_prekey(&self, id: u32) -> wa_rs_core::store::error::Result>> { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT key FROM prekeys WHERE id = ?1 AND device_id = ?2", - params![id, self.device_id], - |row| row.get::<_, Vec>(0), - ); - - match result { - Ok(key) => Ok(Some(key)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn remove_prekey(&self, id: u32) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "DELETE FROM prekeys WHERE id = ?1 AND device_id = ?2", - params![id, self.device_id], - )) - } - - // --- Signed PreKey Operations --- - - async fn store_signed_prekey( - &self, - id: u32, - record: &[u8], - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO signed_prekeys (id, record, device_id) - VALUES (?1, ?2, ?3)", - params![id, record, self.device_id], - )) - } - - async fn load_signed_prekey( - &self, - id: u32, - ) -> wa_rs_core::store::error::Result>> { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT record FROM signed_prekeys WHERE id = ?1 AND device_id = ?2", - params![id, self.device_id], - |row| row.get::<_, Vec>(0), - ); - - match result { - Ok(record) => Ok(Some(record)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn load_all_signed_prekeys( - &self, - ) -> wa_rs_core::store::error::Result)>> { - let conn = self.conn.lock(); - let mut stmt = to_store_err!( - conn.prepare("SELECT id, record FROM signed_prekeys WHERE device_id = ?1") - )?; - - let rows = to_store_err!(stmt.query_map(params![self.device_id], |row| { - Ok((row.get::<_, u32>(0)?, row.get::<_, Vec>(1)?)) - }))?; - - let mut result = Vec::new(); - for row in rows { - result.push(to_store_err!(row)?); - } - - Ok(result) - } - - async fn remove_signed_prekey(&self, id: u32) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "DELETE FROM signed_prekeys WHERE id = ?1 AND device_id = ?2", - params![id, self.device_id], - )) - } - - // --- Sender Key Operations --- - - async fn put_sender_key( - &self, - address: &str, - record: &[u8], - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO sender_keys (address, record, device_id) - VALUES (?1, ?2, ?3)", - params![address, record, self.device_id], - )) - } - - async fn get_sender_key( - &self, - address: &str, - ) -> wa_rs_core::store::error::Result>> { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT record FROM sender_keys WHERE address = ?1 AND device_id = ?2", - params![address, self.device_id], - |row| row.get::<_, Vec>(0), - ); - - match result { - Ok(record) => Ok(Some(record)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn delete_sender_key(&self, address: &str) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "DELETE FROM sender_keys WHERE address = ?1 AND device_id = ?2", - params![address, self.device_id], - )) - } -} - -#[cfg(feature = "whatsapp-web")] -#[async_trait] -impl AppSyncStore for RusqliteStore { - async fn get_sync_key( - &self, - key_id: &[u8], - ) -> wa_rs_core::store::error::Result> { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT key_data FROM app_state_keys WHERE key_id = ?1 AND device_id = ?2", - params![key_id, self.device_id], - |row| { - let key_data: Vec = row.get(0)?; - serde_json::from_slice(&key_data) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) - }, - ); - - match result { - Ok(key) => Ok(Some(key)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn set_sync_key( - &self, - key_id: &[u8], - key: AppStateSyncKey, - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - let key_data = to_store_err!(serde_json::to_vec(&key))?; - - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO app_state_keys (key_id, key_data, device_id) - VALUES (?1, ?2, ?3)", - params![key_id, key_data, self.device_id], - )) - } - - async fn get_version(&self, name: &str) -> wa_rs_core::store::error::Result { - let conn = self.conn.lock(); - let state_data: Vec = to_store_err!(conn.query_row( - "SELECT state_data FROM app_state_versions WHERE name = ?1 AND device_id = ?2", - params![name, self.device_id], - |row| row.get(0), - ))?; - - to_store_err!(serde_json::from_slice(&state_data)) - } - - async fn set_version( - &self, - name: &str, - state: HashState, - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - let state_data = to_store_err!(serde_json::to_vec(&state))?; - - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO app_state_versions (name, state_data, device_id) - VALUES (?1, ?2, ?3)", - params![name, state_data, self.device_id], - )) - } - - async fn put_mutation_macs( - &self, - name: &str, - version: u64, - mutations: &[AppStateMutationMAC], - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - - for mutation in mutations { - let index_mac = to_store_err!(serde_json::to_vec(&mutation.index_mac))?; - let value_mac = to_store_err!(serde_json::to_vec(&mutation.value_mac))?; - - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO app_state_mutation_macs - (name, version, index_mac, value_mac, device_id) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![name, i64::try_from(version).unwrap_or(i64::MAX), index_mac, value_mac, self.device_id], - ))?; - } - - Ok(()) - } - - async fn get_mutation_mac( - &self, - name: &str, - index_mac: &[u8], - ) -> wa_rs_core::store::error::Result>> { - let conn = self.conn.lock(); - let index_mac_json = to_store_err!(serde_json::to_vec(index_mac))?; - - let result = conn.query_row( - "SELECT value_mac FROM app_state_mutation_macs - WHERE name = ?1 AND index_mac = ?2 AND device_id = ?3", - params![name, index_mac_json, self.device_id], - |row| row.get::<_, Vec>(0), - ); - - match result { - Ok(mac) => Ok(Some(mac)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn delete_mutation_macs( - &self, - name: &str, - index_macs: &[Vec], - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - - for index_mac in index_macs { - let index_mac_json = to_store_err!(serde_json::to_vec(index_mac))?; - - to_store_err!(execute: conn.execute( - "DELETE FROM app_state_mutation_macs - WHERE name = ?1 AND index_mac = ?2 AND device_id = ?3", - params![name, index_mac_json, self.device_id], - ))?; - } - - Ok(()) - } -} - -#[cfg(feature = "whatsapp-web")] -#[async_trait] -impl ProtocolStore for RusqliteStore { - // --- SKDM Tracking --- - - async fn get_skdm_recipients( - &self, - group_jid: &str, - ) -> wa_rs_core::store::error::Result> { - let conn = self.conn.lock(); - let mut stmt = to_store_err!(conn.prepare( - "SELECT device_jid FROM skdm_recipients WHERE group_jid = ?1 AND device_id = ?2" - ))?; - - let rows = to_store_err!(stmt.query_map(params![group_jid, self.device_id], |row| { - row.get::<_, String>(0) - }))?; - - let mut result = Vec::new(); - for row in rows { - let jid_str = to_store_err!(row)?; - if let Ok(jid) = jid_str.parse() { - result.push(jid); - } - } - - Ok(result) - } - - async fn add_skdm_recipients( - &self, - group_jid: &str, - device_jids: &[Jid], - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - let now = chrono::Utc::now().timestamp(); - - for device_jid in device_jids { - to_store_err!(execute: conn.execute( - "INSERT OR IGNORE INTO skdm_recipients (group_jid, device_jid, device_id, created_at) - VALUES (?1, ?2, ?3, ?4)", - params![group_jid, device_jid.to_string(), self.device_id, now], - ))?; - } - - Ok(()) - } - - async fn clear_skdm_recipients(&self, group_jid: &str) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "DELETE FROM skdm_recipients WHERE group_jid = ?1 AND device_id = ?2", - params![group_jid, self.device_id], - )) - } - - // --- LID-PN Mapping --- - - async fn get_lid_mapping( - &self, - lid: &str, - ) -> wa_rs_core::store::error::Result> { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT lid, phone_number, created_at, learning_source, updated_at - FROM lid_pn_mapping WHERE lid = ?1 AND device_id = ?2", - params![lid, self.device_id], - |row| { - Ok(LidPnMappingEntry { - lid: row.get(0)?, - phone_number: row.get(1)?, - created_at: row.get(2)?, - learning_source: row.get(3)?, - updated_at: row.get(4)?, - }) - }, - ); - - match result { - Ok(entry) => Ok(Some(entry)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn get_pn_mapping( - &self, - phone: &str, - ) -> wa_rs_core::store::error::Result> { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT lid, phone_number, created_at, learning_source, updated_at - FROM lid_pn_mapping WHERE phone_number = ?1 AND device_id = ?2 - ORDER BY updated_at DESC LIMIT 1", - params![phone, self.device_id], - |row| { - Ok(LidPnMappingEntry { - lid: row.get(0)?, - phone_number: row.get(1)?, - created_at: row.get(2)?, - learning_source: row.get(3)?, - updated_at: row.get(4)?, - }) - }, - ); - - match result { - Ok(entry) => Ok(Some(entry)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn put_lid_mapping( - &self, - entry: &LidPnMappingEntry, - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO lid_pn_mapping - (lid, phone_number, created_at, learning_source, updated_at, device_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - entry.lid, - entry.phone_number, - entry.created_at, - entry.learning_source, - entry.updated_at, - self.device_id, - ], - )) - } - - async fn get_all_lid_mappings( - &self, - ) -> wa_rs_core::store::error::Result> { - let conn = self.conn.lock(); - let mut stmt = to_store_err!(conn.prepare( - "SELECT lid, phone_number, created_at, learning_source, updated_at - FROM lid_pn_mapping WHERE device_id = ?1" - ))?; - - let rows = to_store_err!(stmt.query_map(params![self.device_id], |row| { - Ok(LidPnMappingEntry { - lid: row.get(0)?, - phone_number: row.get(1)?, - created_at: row.get(2)?, - learning_source: row.get(3)?, - updated_at: row.get(4)?, - }) - }))?; - - let mut result = Vec::new(); - for row in rows { - result.push(to_store_err!(row)?); - } - - Ok(result) - } - - // --- Base Key Collision Detection --- - - async fn save_base_key( - &self, - address: &str, - message_id: &str, - base_key: &[u8], - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - let now = chrono::Utc::now().timestamp(); - - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO base_keys (address, message_id, base_key, device_id, created_at) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![address, message_id, base_key, self.device_id, now], - )) - } - - async fn has_same_base_key( - &self, - address: &str, - message_id: &str, - current_base_key: &[u8], - ) -> wa_rs_core::store::error::Result { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT base_key FROM base_keys - WHERE address = ?1 AND message_id = ?2 AND device_id = ?3", - params![address, message_id, self.device_id], - |row| { - let saved_key: Vec = row.get(0)?; - Ok(saved_key == current_base_key) - }, - ); - - match result { - Ok(same) => Ok(same), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn delete_base_key( - &self, - address: &str, - message_id: &str, - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "DELETE FROM base_keys WHERE address = ?1 AND message_id = ?2 AND device_id = ?3", - params![address, message_id, self.device_id], - )) - } - - // --- Device Registry --- - - async fn update_device_list( - &self, - record: DeviceListRecord, - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - let devices_json = to_store_err!(serde_json::to_string(&record.devices))?; - let now = chrono::Utc::now().timestamp(); - - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO device_registry - (user_id, devices_json, timestamp, phash, device_id, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - record.user, - devices_json, - record.timestamp, - record.phash, - self.device_id, - now, - ], - )) - } - - async fn get_devices( - &self, - user: &str, - ) -> wa_rs_core::store::error::Result> { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT user_id, devices_json, timestamp, phash - FROM device_registry WHERE user_id = ?1 AND device_id = ?2", - params![user, self.device_id], - |row| { - // Helper to convert errors to rusqlite::Error - fn to_rusqlite_err( - e: E, - ) -> rusqlite::Error { - rusqlite::Error::ToSqlConversionFailure(Box::new(e)) - } - - let devices_json: String = row.get(1)?; - let devices: Vec = - serde_json::from_str(&devices_json).map_err(to_rusqlite_err)?; - Ok(DeviceListRecord { - user: row.get(0)?, - devices, - timestamp: row.get(2)?, - phash: row.get(3)?, - }) - }, - ); - - match result { - Ok(record) => Ok(Some(record)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - // --- Sender Key Status (Lazy Deletion) --- - - async fn mark_forget_sender_key( - &self, - group_jid: &str, - participant: &str, - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - let now = chrono::Utc::now().timestamp(); - - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO sender_key_status (group_jid, participant, device_id, marked_at) - VALUES (?1, ?2, ?3, ?4)", - params![group_jid, participant, self.device_id, now], - )) - } - - async fn consume_forget_marks( - &self, - group_jid: &str, - ) -> wa_rs_core::store::error::Result> { - let conn = self.conn.lock(); - let mut stmt = to_store_err!(conn.prepare( - "SELECT participant FROM sender_key_status - WHERE group_jid = ?1 AND device_id = ?2" - ))?; - - let rows = to_store_err!(stmt.query_map(params![group_jid, self.device_id], |row| { - row.get::<_, String>(0) - }))?; - - let mut result = Vec::new(); - for row in rows { - result.push(to_store_err!(row)?); - } - - // Delete the marks after consuming them - to_store_err!(execute: conn.execute( - "DELETE FROM sender_key_status WHERE group_jid = ?1 AND device_id = ?2", - params![group_jid, self.device_id], - ))?; - - Ok(result) - } - - // --- TcToken Storage --- - - async fn get_tc_token( - &self, - jid: &str, - ) -> wa_rs_core::store::error::Result> { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT token, token_timestamp, sender_timestamp FROM tc_tokens - WHERE jid = ?1 AND device_id = ?2", - params![jid, self.device_id], - |row| { - Ok(TcTokenEntry { - token: row.get(0)?, - token_timestamp: row.get(1)?, - sender_timestamp: row.get(2)?, - }) - }, - ); - - match result { - Ok(entry) => Ok(Some(entry)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn put_tc_token( - &self, - jid: &str, - entry: &TcTokenEntry, - ) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - let now = chrono::Utc::now().timestamp(); - - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO tc_tokens - (jid, token, token_timestamp, sender_timestamp, device_id, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - jid, - entry.token, - entry.token_timestamp, - entry.sender_timestamp, - self.device_id, - now, - ], - )) - } - - async fn delete_tc_token(&self, jid: &str) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - to_store_err!(execute: conn.execute( - "DELETE FROM tc_tokens WHERE jid = ?1 AND device_id = ?2", - params![jid, self.device_id], - )) - } - - async fn get_all_tc_token_jids(&self) -> wa_rs_core::store::error::Result> { - let conn = self.conn.lock(); - let mut stmt = - to_store_err!(conn.prepare("SELECT jid FROM tc_tokens WHERE device_id = ?1"))?; - - let rows = to_store_err!( - stmt.query_map(params![self.device_id], |row| { row.get::<_, String>(0) }) - )?; - - let mut result = Vec::new(); - for row in rows { - result.push(to_store_err!(row)?); - } - - Ok(result) - } - - async fn delete_expired_tc_tokens( - &self, - cutoff_timestamp: i64, - ) -> wa_rs_core::store::error::Result { - let conn = self.conn.lock(); - let deleted = conn - .execute( - "DELETE FROM tc_tokens WHERE token_timestamp < ?1 AND device_id = ?2", - params![cutoff_timestamp, self.device_id], - ) - .map_err(|e| wa_rs_core::store::error::StoreError::Database(e.to_string()))?; - - let deleted = u32::try_from(deleted).map_err(|_| { - wa_rs_core::store::error::StoreError::Database(format!( - "Affected row count overflowed u32: {deleted}" - )) - })?; - - Ok(deleted) - } -} - -#[cfg(feature = "whatsapp-web")] -#[async_trait] -impl DeviceStoreTrait for RusqliteStore { - async fn save(&self, device: &CoreDevice) -> wa_rs_core::store::error::Result<()> { - let conn = self.conn.lock(); - - // Serialize KeyPairs to bytes - let noise_key = { - let mut bytes = Vec::new(); - let priv_key = device.noise_key.private_key.serialize(); - bytes.extend_from_slice(priv_key.as_slice()); - bytes.extend_from_slice(device.noise_key.public_key.public_key_bytes()); - bytes - }; - - let identity_key = { - let mut bytes = Vec::new(); - let priv_key = device.identity_key.private_key.serialize(); - bytes.extend_from_slice(priv_key.as_slice()); - bytes.extend_from_slice(device.identity_key.public_key.public_key_bytes()); - bytes - }; - - let signed_pre_key = { - let mut bytes = Vec::new(); - let priv_key = device.signed_pre_key.private_key.serialize(); - bytes.extend_from_slice(priv_key.as_slice()); - bytes.extend_from_slice(device.signed_pre_key.public_key.public_key_bytes()); - bytes - }; - - let account = device.account.as_ref().map(|a| a.encode_to_vec()); - - to_store_err!(execute: conn.execute( - "INSERT OR REPLACE INTO device ( - id, lid, pn, registration_id, noise_key, identity_key, - signed_pre_key, signed_pre_key_id, signed_pre_key_signature, - adv_secret_key, account, push_name, app_version_primary, - app_version_secondary, app_version_tertiary, app_version_last_fetched_ms, - edge_routing_info, props_hash - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", - params![ - self.device_id, - device.lid.as_ref().map(|j| j.to_string()), - device.pn.as_ref().map(|j| j.to_string()), - device.registration_id, - noise_key, - identity_key, - signed_pre_key, - device.signed_pre_key_id, - device.signed_pre_key_signature.to_vec(), - device.adv_secret_key.to_vec(), - account, - &device.push_name, - device.app_version_primary, - device.app_version_secondary, - device.app_version_tertiary, - device.app_version_last_fetched_ms, - device.edge_routing_info.as_ref().map(|v| v.clone()), - device.props_hash.as_ref().map(|v| v.clone()), - ], - )) - } - - async fn load(&self) -> wa_rs_core::store::error::Result> { - let conn = self.conn.lock(); - let result = conn.query_row( - "SELECT * FROM device WHERE id = ?1", - params![self.device_id], - |row| { - // Helper to convert errors to rusqlite::Error - fn to_rusqlite_err( - e: E, - ) -> rusqlite::Error { - rusqlite::Error::ToSqlConversionFailure(Box::new(e)) - } - - // Deserialize KeyPairs from bytes (64 bytes each) - let noise_key_bytes: Vec = row.get("noise_key")?; - let identity_key_bytes: Vec = row.get("identity_key")?; - let signed_pre_key_bytes: Vec = row.get("signed_pre_key")?; - - if noise_key_bytes.len() != 64 - || identity_key_bytes.len() != 64 - || signed_pre_key_bytes.len() != 64 - { - return Err(rusqlite::Error::InvalidParameterName("key_pair".into())); - } - - use wa_rs_core::libsignal::protocol::{KeyPair, PrivateKey, PublicKey}; - - let noise_key = KeyPair::new( - PublicKey::from_djb_public_key_bytes(&noise_key_bytes[32..64]) - .map_err(to_rusqlite_err)?, - PrivateKey::deserialize(&noise_key_bytes[0..32]).map_err(to_rusqlite_err)?, - ); - - let identity_key = KeyPair::new( - PublicKey::from_djb_public_key_bytes(&identity_key_bytes[32..64]) - .map_err(to_rusqlite_err)?, - PrivateKey::deserialize(&identity_key_bytes[0..32]).map_err(to_rusqlite_err)?, - ); - - let signed_pre_key = KeyPair::new( - PublicKey::from_djb_public_key_bytes(&signed_pre_key_bytes[32..64]) - .map_err(to_rusqlite_err)?, - PrivateKey::deserialize(&signed_pre_key_bytes[0..32]) - .map_err(to_rusqlite_err)?, - ); - - let lid_str: Option = row.get("lid")?; - let pn_str: Option = row.get("pn")?; - let signature_bytes: Vec = row.get("signed_pre_key_signature")?; - let adv_secret_bytes: Vec = row.get("adv_secret_key")?; - let account_bytes: Option> = row.get("account")?; - - let mut signature = [0u8; 64]; - let mut adv_secret = [0u8; 32]; - signature.copy_from_slice(&signature_bytes); - adv_secret.copy_from_slice(&adv_secret_bytes); - - let account = if let Some(bytes) = account_bytes { - Some( - wa_rs_proto::whatsapp::AdvSignedDeviceIdentity::decode(&*bytes) - .map_err(to_rusqlite_err)?, - ) - } else { - None - }; - - Ok(CoreDevice { - lid: lid_str.and_then(|s| s.parse().ok()), - pn: pn_str.and_then(|s| s.parse().ok()), - registration_id: row.get("registration_id")?, - noise_key, - identity_key, - signed_pre_key, - signed_pre_key_id: row.get("signed_pre_key_id")?, - signed_pre_key_signature: signature, - adv_secret_key: adv_secret, - account, - push_name: row.get("push_name")?, - app_version_primary: row.get("app_version_primary")?, - app_version_secondary: row.get("app_version_secondary")?, - app_version_tertiary: row.get("app_version_tertiary")?, - app_version_last_fetched_ms: row.get("app_version_last_fetched_ms")?, - edge_routing_info: row.get("edge_routing_info")?, - props_hash: row.get("props_hash")?, - ..Default::default() - }) - }, - ); - - match result { - Ok(device) => Ok(Some(device)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(wa_rs_core::store::error::StoreError::Database( - e.to_string(), - )), - } - } - - async fn exists(&self) -> wa_rs_core::store::error::Result { - let conn = self.conn.lock(); - let count: i64 = to_store_err!(conn.query_row( - "SELECT COUNT(*) FROM device WHERE id = ?1", - params![self.device_id], - |row| row.get(0), - ))?; - - Ok(count > 0) - } - - async fn create(&self) -> wa_rs_core::store::error::Result { - // Device already created in constructor, just return the ID - Ok(self.device_id) - } - - async fn snapshot_db( - &self, - name: &str, - extra_content: Option<&[u8]>, - ) -> wa_rs_core::store::error::Result<()> { - // Create a snapshot by copying the database file - let snapshot_path = format!("{}.snapshot.{}", self.db_path, name); - - to_store_err!(std::fs::copy(&self.db_path, &snapshot_path))?; - - // If extra_content is provided, save it alongside - if let Some(content) = extra_content { - let content_path = format!("{}.extra", snapshot_path); - to_store_err!(std::fs::write(&content_path, content))?; - } - - Ok(()) - } -} - -#[cfg(test)] -#[path = "whatsapp_storage_tests.rs"] -mod tests; diff --git a/src/openhuman/channels/providers/whatsapp_storage_tests.rs b/src/openhuman/channels/providers/whatsapp_storage_tests.rs deleted file mode 100644 index 5305fab7c..000000000 --- a/src/openhuman/channels/providers/whatsapp_storage_tests.rs +++ /dev/null @@ -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()); -} diff --git a/src/openhuman/channels/providers/whatsapp_web.rs b/src/openhuman/channels/providers/whatsapp_web.rs index 65abc6f64..fff044e89 100644 --- a/src/openhuman/channels/providers/whatsapp_web.rs +++ b/src/openhuman/channels/providers/whatsapp_web.rs @@ -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` 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, - /// Custom pair code (optional) + /// Optional pre-allocated pair code paired with `pair_phone`. pair_code: Option, - /// 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, - /// Bot handle for shutdown - bot_handle: Arc>>>, - /// Client handle for sending messages and typing indicators - client: Arc>>>, - /// Message sender channel + /// Bot run handle, retained for graceful shutdown. + bot_handle: Arc>>, + /// Live client used for outbound calls; populated after `Bot::build` returns. + client: Arc>>>, + /// 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, + /// 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>>, + /// Sink for inbound `ChannelMessage`s. Populated when [`Channel::listen`] + /// is called and shared with the event-handler closure. tx: Arc>>>, } +#[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, @@ -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, `@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 { + /// 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 { 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::() + .parse::() .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) -> 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 `@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(¬ify_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; diff --git a/src/openhuman/channels/providers/whatsapp_web_tests.rs b/src/openhuman/channels/providers/whatsapp_web_tests.rs index ed2c69c99..ba068f0b3 100644 --- a/src/openhuman/channels/providers/whatsapp_web_tests.rs +++ b/src/openhuman/channels/providers/whatsapp_web_tests.rs @@ -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 +/// `+` 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")); +}