Browse Source

feat: implement WhatsApp client wrapper with event handling and message sending capabilities

- Introduced `WaEvent` enum for event representation including message, contact updates, and errors.
- Created `WaHandle` struct for sending messages and downloading images.
- Implemented `start` function to initialize the WhatsApp client and handle events.
- Added `Translator` struct to translate library events into `WaEvent`s.
- Developed functions for handling message batches, contact updates, and history sync.
- Included utility functions for message description and JID formatting.
- Added tests for message handling and placeholder detection.
Giancalo Voci 4 days ago
commit
43873aee9e
11 changed files with 9334 additions and 0 deletions
  1. 16 0
      .gitignore
  2. 4249 0
      Cargo.lock
  3. 23 0
      Cargo.toml
  4. 527 0
      src/ai.rs
  5. 837 0
      src/app.rs
  6. 679 0
      src/autoreply.rs
  7. 454 0
      src/config.rs
  8. 593 0
      src/history.rs
  9. 147 0
      src/main.rs
  10. 636 0
      src/ui.rs
  11. 1173 0
      src/whatsapp.rs

+ 16 - 0
.gitignore

@@ -0,0 +1,16 @@
+# Cargo build output
+/target
+
+# Local secrets / runtime data (must never be committed)
+/config.toml
+.env
+*.db
+*.db-journal
+*.db-wal
+*.db-shm
+
+# Editor / OS noise
+.vscode/
+.idea/
+Thumbs.db
+.DS_Store

+ 4249 - 0
Cargo.lock

@@ -0,0 +1,4249 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aes"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
+dependencies = [
+ "cipher",
+ "cpubits",
+ "cpufeatures 0.3.1",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "approx"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "atomic"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "aws-lc-rs"
+version = "1.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13"
+dependencies = [
+ "aws-lc-sys",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+ "pkg-config",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "bit-set"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "block-padding"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "bon"
+version = "3.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "214f5df094ce551a10a30ffb9c70243d61f121a3d985a6495933e181dee6a7d2"
+dependencies = [
+ "bon-macros",
+]
+
+[[package]]
+name = "bon-macros"
+version = "3.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5"
+dependencies = [
+ "darling 0.24.1",
+ "ident_case",
+ "prettyplease 0.3.0",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "buffa"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a92f2f5df67a9d5ccfc65237bfc954c56328aee40a04a9b92381ecba370be246"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "foldhash 0.1.5",
+ "hashbrown 0.15.5",
+ "once_cell",
+ "rustversion",
+ "serde",
+ "serde_json",
+ "smoothutf8",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "buffa-build"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eee6b6b4a8d23f1f98a043f188f4d9d2ef8bee43191f42e93ac577f3c3b0f334"
+dependencies = [
+ "buffa",
+ "buffa-codegen",
+ "tempfile",
+]
+
+[[package]]
+name = "buffa-codegen"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4bea10ef85ea3d06a20412c67cb612527feb25f33367eab850139441fb5dd9"
+dependencies = [
+ "buffa",
+ "buffa-descriptor",
+ "prettyplease 0.2.37",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "buffa-descriptor"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b7dd79a6898e70dac8adae959c8ad2cae51ed96397935c91c996280cedcb55e"
+dependencies = [
+ "buffa",
+ "rustversion",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "by_address"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "cbc"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "cc"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link",
+]
+
+[[package]]
+name = "cipher"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
+dependencies = [
+ "block-buffer 0.12.1",
+ "crypto-common 0.2.2",
+ "inout",
+]
+
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
+[[package]]
+name = "combine"
+version = "4.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "compact_str"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
+[[package]]
+name = "compact_str"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "serde",
+ "static_assertions",
+ "zmij",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpubits"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
+name = "crossterm"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
+dependencies = [
+ "bitflags 2.13.2",
+ "crossterm_winapi",
+ "derive_more",
+ "document-features",
+ "mio",
+ "parking_lot",
+ "rustix",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "csscolorparser"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
+dependencies = [
+ "lab",
+ "phf",
+]
+
+[[package]]
+name = "ctr"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
+dependencies = [
+ "cmov",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "curve25519-dalek-derive",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+dependencies = [
+ "darling_core 0.21.3",
+ "darling_macro 0.21.3",
+]
+
+[[package]]
+name = "darling"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
+dependencies = [
+ "darling_core 0.24.1",
+ "darling_macro 0.24.1",
+]
+
+[[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.119",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+dependencies = [
+ "darling_core 0.21.3",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
+dependencies = [
+ "darling_core 0.24.1",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "deltae"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
+
+[[package]]
+name = "deranged"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "diesel"
+version = "2.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe9f7eaef33febd60290c5a9f3b0571d03c4c04a24739cd64808ce30e65a5d3"
+dependencies = [
+ "diesel_derives",
+ "downcast-rs",
+ "libsqlite3-sys",
+ "r2d2",
+ "sqlite-wasm-rs",
+ "time",
+]
+
+[[package]]
+name = "diesel_derives"
+version = "2.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecbd51fb6c020672543641167efa4e6417ff7ad76849ed556ace3595e72de03a"
+dependencies = [
+ "diesel_table_macro_syntax",
+ "dsl_auto_type",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[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.119",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "crypto-common 0.1.7",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "crypto-common 0.2.2",
+ "ctutils",
+]
+
+[[package]]
+name = "dirs"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[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 0.21.3",
+ "either",
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "either"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
+
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
+name = "fancy-regex"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
+dependencies = [
+ "bit-set",
+ "regex",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "fiat-crypto"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
+
+[[package]]
+name = "filedescriptor"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
+dependencies = [
+ "libc",
+ "thiserror 1.0.69",
+ "winapi",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
+
+[[package]]
+name = "finl_unicode"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2"
+
+[[package]]
+name = "fixedbitset"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+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 = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "ghash"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
+dependencies = [
+ "polyval",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
+ "serde",
+]
+
+[[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]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hkdf"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hybrid-array"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "hyper"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "httparse",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "indoc"
+version = "2.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "inout"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f"
+dependencies = [
+ "block-padding",
+ "hybrid-array",
+]
+
+[[package]]
+name = "instability"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
+dependencies = [
+ "darling 0.24.1",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.21",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom 0.4.3",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "kasuari"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
+dependencies = [
+ "hashbrown 0.17.1",
+ "portable-atomic",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "lab"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
+
+[[package]]
+name = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "line-clipping"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "litrs"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "lru"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
+dependencies = [
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "lru-slab"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+
+[[package]]
+name = "mac_address"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381"
+dependencies = [
+ "nix 0.31.3",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "md5"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmem"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9423d6affd681fb4d25d35885d0d0262c52f8ade16e96f87c064c442e5aa5d47"
+dependencies = [
+ "migrations_internals",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+ "memoffset",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-derive"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "ordered-float"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "palette"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
+dependencies = [
+ "approx",
+ "libm",
+ "palette_derive",
+ "palette_math",
+]
+
+[[package]]
+name = "palette_derive"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
+dependencies = [
+ "by_address",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "palette_math"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pest"
+version = "2.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd47a460a9cbc3b3429c7e22bf0d76653e779319bae45a499ca010c9a990bb23"
+dependencies = [
+ "memchr",
+ "psm",
+ "stacker",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "203e46697708f7519ab1608393cf89239cafb46b8e6d08a8ffe359359b11ef44"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e84ae6340a458d3d6cfc73cf328cf3cfe90b2484cd0de25eb66aa954615ed4d5"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b57f92f32b3dbce6598fd8f5cdf1f06a6072570b8103fa21728fa9036923e710"
+dependencies = [
+ "pest",
+]
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.8",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "polyval"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
+dependencies = [
+ "cpubits",
+ "cpufeatures 0.3.1",
+ "universal-hash",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
+dependencies = [
+ "proc-macro2",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "psm"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "qrcode"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
+
+[[package]]
+name = "quinn"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.21",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
+dependencies = [
+ "aws-lc-rs",
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.3",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.21",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+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.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "ratatui"
+version = "0.30.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
+dependencies = [
+ "instability",
+ "ratatui-core",
+ "ratatui-crossterm",
+ "ratatui-macros",
+ "ratatui-termina",
+ "ratatui-termwiz",
+ "ratatui-widgets",
+ "serde",
+]
+
+[[package]]
+name = "ratatui-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
+dependencies = [
+ "bitflags 2.13.2",
+ "compact_str 0.9.1",
+ "critical-section",
+ "hashbrown 0.17.1",
+ "itertools",
+ "kasuari",
+ "lru",
+ "palette",
+ "serde",
+ "strum",
+ "thiserror 2.0.21",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width",
+]
+
+[[package]]
+name = "ratatui-crossterm"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
+dependencies = [
+ "cfg-if",
+ "crossterm",
+ "instability",
+ "ratatui-core",
+]
+
+[[package]]
+name = "ratatui-macros"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814"
+dependencies = [
+ "ratatui-core",
+ "ratatui-widgets",
+]
+
+[[package]]
+name = "ratatui-termina"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2"
+dependencies = [
+ "instability",
+ "ratatui-core",
+ "termina",
+]
+
+[[package]]
+name = "ratatui-termwiz"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977"
+dependencies = [
+ "ratatui-core",
+ "termwiz",
+]
+
+[[package]]
+name = "ratatui-widgets"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
+dependencies = [
+ "bitflags 2.13.2",
+ "hashbrown 0.17.1",
+ "indoc",
+ "instability",
+ "itertools",
+ "line-clipping",
+ "ratatui-core",
+ "serde",
+ "strum",
+ "time",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
+dependencies = [
+ "libredox",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
+ "serde",
+ "serde_json",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rsqlite-vfs"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
+dependencies = [
+ "hashbrown 0.16.1",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "rusqlite"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
+dependencies = [
+ "bitflags 2.13.2",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+ "sqlite-wasm-rs",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
+dependencies = [
+ "aws-lc-rs",
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
+dependencies = [
+ "core-foundation",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "aws-lc-rs",
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schannel"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
+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 = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-big-array"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "sha1"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd_cesu8"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "siphasher"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "smoothutf8"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b4ec95892483d6d94284caccfddb9b77111a4dcac33ed25d624248def0fa5f1"
+dependencies = [
+ "simdutf8",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "sqlite-wasm-rs"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
+dependencies = [
+ "cc",
+ "js-sys",
+ "rsqlite-vfs",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "stacker"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "psm",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termina"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
+dependencies = [
+ "bitflags 2.13.2",
+ "parking_lot",
+ "rustix",
+ "signal-hook",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "terminfo"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
+dependencies = [
+ "fnv",
+ "nom",
+ "phf",
+ "phf_codegen",
+]
+
+[[package]]
+name = "termios"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "termwiz"
+version = "0.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
+dependencies = [
+ "anyhow",
+ "base64 0.22.1",
+ "bitflags 2.13.2",
+ "fancy-regex",
+ "filedescriptor",
+ "finl_unicode",
+ "fixedbitset",
+ "hex",
+ "lazy_static",
+ "libc",
+ "log",
+ "memmem",
+ "nix 0.29.0",
+ "num-derive",
+ "num-traits",
+ "ordered-float",
+ "pest",
+ "pest_derive",
+ "phf",
+ "sha2 0.10.9",
+ "signal-hook",
+ "siphasher",
+ "terminfo",
+ "termios",
+ "thiserror 1.0.69",
+ "ucd-trie",
+ "unicode-segmentation",
+ "vtparse",
+ "wezterm-bidi",
+ "wezterm-blob-leases",
+ "wezterm-color-types",
+ "wezterm-dynamic",
+ "wezterm-input-types",
+ "winapi",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "libc",
+ "num-conv",
+ "num_threads",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "tokio"
+version = "1.53.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "libc",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-websockets"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "httparse",
+ "rand 0.10.3",
+ "ring",
+ "rustls-pki-types",
+ "simdutf8",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+]
+
+[[package]]
+name = "toml"
+version = "0.9.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
+dependencies = [
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime 0.7.5+spec-1.1.0",
+ "toml_parser",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.8+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime 1.1.2+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
+dependencies = [
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-truncate"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
+dependencies = [
+ "itertools",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[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.2",
+ "ctutils",
+]
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
+dependencies = [
+ "base64 0.23.1",
+ "log",
+ "percent-encoding",
+ "rustls",
+ "rustls-pki-types",
+ "ureq-proto",
+ "utf8-zero",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ureq-proto"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
+dependencies = [
+ "base64 0.23.1",
+ "http",
+ "httparse",
+ "log",
+]
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8-zero"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
+dependencies = [
+ "atomic",
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vtparse"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "wacore"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6139a98fece24956f1d6bf164c82c5daefb43834813d96a879b41e010707c2c2"
+dependencies = [
+ "aes",
+ "anyhow",
+ "async-channel",
+ "async-lock",
+ "async-trait",
+ "base64 0.22.1",
+ "bon",
+ "buffa",
+ "buffa-build",
+ "bytes",
+ "chrono",
+ "compact_str 0.10.0",
+ "ctr",
+ "event-listener",
+ "futures",
+ "hashbrown 0.17.1",
+ "hex",
+ "hkdf",
+ "hmac",
+ "itoa",
+ "log",
+ "md5",
+ "portable-atomic",
+ "rand 0.10.3",
+ "serde",
+ "serde-big-array",
+ "serde_json",
+ "sha1",
+ "sha2 0.11.1",
+ "smallvec",
+ "smoothutf8",
+ "subtle",
+ "thiserror 2.0.21",
+ "wacore-appstate",
+ "wacore-binary",
+ "wacore-derive",
+ "wacore-libsignal",
+ "wacore-noise",
+ "waproto",
+ "zeroize",
+]
+
+[[package]]
+name = "wacore-appstate"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5afb5372c68d1cda49b7e86a81a01302b7c71b78e8555178c7cf3be01f09ecc3"
+dependencies = [
+ "anyhow",
+ "buffa",
+ "hex",
+ "hkdf",
+ "hmac",
+ "log",
+ "serde",
+ "serde-big-array",
+ "serde_json",
+ "sha2 0.11.1",
+ "thiserror 2.0.21",
+ "wacore-binary",
+ "wacore-libsignal",
+ "waproto",
+]
+
+[[package]]
+name = "wacore-binary"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e56452f93ac1e6167a6ebafec23971fe1ca9332b27cc887520f4a6753956fde"
+dependencies = [
+ "bytes",
+ "compact_str 0.10.0",
+ "itoa",
+ "serde",
+ "serde_json",
+ "smallvec",
+ "smoothutf8",
+ "stable_deref_trait",
+ "yoke",
+ "zlib-rs",
+]
+
+[[package]]
+name = "wacore-derive"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53cba66b01df4bef777c3d131af434dea0f3c8a7da2e42d7fb74e11fd65fb094"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "wacore-libsignal"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbf8e90eebdf77703305089c1dfe672dddf203e0238e8f47163f76e7f9d5c713"
+dependencies = [
+ "aes",
+ "async-lock",
+ "async-trait",
+ "buffa",
+ "bytes",
+ "cbc",
+ "chrono",
+ "ctr",
+ "curve25519-dalek",
+ "ghash",
+ "hex",
+ "hkdf",
+ "hmac",
+ "log",
+ "portable-atomic",
+ "rand 0.10.3",
+ "serde",
+ "sha1",
+ "sha2 0.11.1",
+ "subtle",
+ "thiserror 2.0.21",
+ "wacore-derive",
+ "waproto",
+ "x25519-dalek",
+]
+
+[[package]]
+name = "wacore-noise"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a15b03c1e5e4bbf20d2d875c96649aaff82d149f025ae3a1421a1b6a9c426114"
+dependencies = [
+ "anyhow",
+ "buffa",
+ "bytes",
+ "hkdf",
+ "log",
+ "rand 0.10.3",
+ "sha2 0.11.1",
+ "thiserror 2.0.21",
+ "wacore-binary",
+ "wacore-libsignal",
+ "waproto",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "waproto"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd19a927d4781e92382a478d234f74baeacb4822ceeb45c665bb9cb321622c35"
+dependencies = [
+ "buffa",
+ "buffa-build",
+ "buffa-descriptor",
+ "bytes",
+ "heck",
+ "serde",
+ "sha2 0.11.1",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
+dependencies = [
+ "js-sys",
+ "tokio",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "wezterm-bidi"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec"
+dependencies = [
+ "log",
+ "wezterm-dynamic",
+]
+
+[[package]]
+name = "wezterm-blob-leases"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
+dependencies = [
+ "getrandom 0.3.4",
+ "mac_address",
+ "sha2 0.10.9",
+ "thiserror 1.0.69",
+ "uuid",
+]
+
+[[package]]
+name = "wezterm-color-types"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296"
+dependencies = [
+ "csscolorparser",
+ "deltae",
+ "lazy_static",
+ "wezterm-dynamic",
+]
+
+[[package]]
+name = "wezterm-dynamic"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
+dependencies = [
+ "log",
+ "ordered-float",
+ "strsim",
+ "thiserror 1.0.69",
+ "wezterm-dynamic-derive",
+]
+
+[[package]]
+name = "wezterm-dynamic-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "wezterm-input-types"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e"
+dependencies = [
+ "bitflags 1.3.2",
+ "euclid",
+ "lazy_static",
+ "serde",
+ "wezterm-dynamic",
+]
+
+[[package]]
+name = "wh"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "base64 0.23.1",
+ "chrono",
+ "dirs",
+ "qrcode",
+ "ratatui",
+ "reqwest",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "tokio",
+ "toml 1.1.8+spec-1.1.0",
+ "whatsapp-rust",
+]
+
+[[package]]
+name = "whatsapp-rust"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cb354c32641cfe832bf5c5767a016762234c5efd1b4ce979e2b3e32175d96bc"
+dependencies = [
+ "anyhow",
+ "async-channel",
+ "async-lock",
+ "async-trait",
+ "base64 0.22.1",
+ "buffa",
+ "bytes",
+ "chrono",
+ "event-listener",
+ "futures",
+ "getrandom 0.4.3",
+ "hashbrown 0.17.1",
+ "hex",
+ "itoa",
+ "log",
+ "portable-atomic",
+ "rand 0.10.3",
+ "scopeguard",
+ "serde",
+ "serde_json",
+ "smallvec",
+ "thiserror 2.0.21",
+ "tokio",
+ "wacore",
+ "wacore-binary",
+ "waproto",
+ "whatsapp-rust-sqlite-storage",
+ "whatsapp-rust-tokio-transport",
+ "whatsapp-rust-ureq-http-client",
+ "zeroize",
+]
+
+[[package]]
+name = "whatsapp-rust-sqlite-storage"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee70216db150b33c1c7cb01dc81e37f70b3edf8c24cebcd75a7ade5c011720d1"
+dependencies = [
+ "async-trait",
+ "buffa",
+ "buffa-build",
+ "bytes",
+ "diesel",
+ "diesel_migrations",
+ "libsqlite3-sys",
+ "log",
+ "scheduled-thread-pool",
+ "serde_json",
+ "sha2 0.11.1",
+ "tokio",
+ "wacore",
+]
+
+[[package]]
+name = "whatsapp-rust-tokio-transport"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02ae442f8a6e747c777225ce1808f2ce5311963cf2dccb17a1f304c749f01832"
+dependencies = [
+ "anyhow",
+ "async-channel",
+ "async-trait",
+ "bytes",
+ "futures-util",
+ "http",
+ "log",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tokio-websockets",
+ "wacore",
+ "webpki-roots",
+]
+
+[[package]]
+name = "whatsapp-rust-ureq-http-client"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebb64db9f84faa5eddb851ba7f44566363523bbdb88d5772fcc7d168e177797"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "tokio",
+ "ureq",
+ "wacore",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "x25519-dalek"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6"
+dependencies = [
+ "curve25519-dalek",
+ "rand_core 0.10.1",
+ "zeroize",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

+ 23 - 0
Cargo.toml

@@ -0,0 +1,23 @@
+[package]
+name = "wh"
+version = "0.1.0"
+edition = "2024"
+publish = false
+
+[dependencies]
+# default-features = false because the default "simd" feature requires nightly (portable_simd).
+# Remaining features mirror the crate defaults: sqlite session store, tokio transport/runtime, ureq client.
+whatsapp-rust = { version = "0.7", default-features = false, features = ["sqlite-storage", "tokio-transport", "tokio-runtime", "ureq-client", "tokio-native", "signal"] }
+tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] }
+ratatui = "0.30"
+reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
+# rusqlite 0.39 -> libsqlite3-sys 0.37, same as diesel 2.3 pulled by whatsapp-rust-sqlite-storage (links = "sqlite3").
+rusqlite = { version = "0.39", features = ["bundled"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+toml = "1.1.8"
+anyhow = "1"
+qrcode = { version = "0.14", default-features = false }
+dirs = "7"
+base64 = "0.23"
+chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }

+ 527 - 0
src/ai.rs

@@ -0,0 +1,527 @@
+//! OpenAI-compatible chat completions client used by the per-chat AI autoreply.
+
+use std::fmt;
+use std::time::Duration;
+
+use anyhow::{Context, anyhow, bail};
+use base64::Engine;
+use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
+use serde_json::{Value, json};
+
+use crate::config::Config;
+
+/// Maximum accepted image size, in bytes, for a vision request.
+pub const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
+const MISSING_VISION_MODEL_MESSAGE: &str = "modello vision non configurato";
+const EMPTY_IMAGE_MESSAGE: &str = "immagine vuota";
+const TEXT_PART_TYPE: &str = "text";
+const IMAGE_PART_TYPE: &str = "image_url";
+const REQUEST_TIMEOUT_SECONDS: u64 = 60;
+const ERROR_BODY_MAX_CHARS: usize = 300;
+const CHAT_COMPLETIONS_PATH: &str = "/chat/completions";
+const MISSING_API_KEY_MESSAGE: &str = "openai_api_key mancante in config.toml";
+
+/// Author of a single conversation turn sent to the model.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Role {
+    System,
+    User,
+    Assistant,
+}
+
+impl Role {
+    /// Returns the role name expected by the chat completions API.
+    ///
+    /// Inputs: the role. Output: the wire name ("system", "user" or "assistant").
+    /// Side effects: none.
+    fn as_wire_str(self) -> &'static str {
+        match self {
+            Role::System => "system",
+            Role::User => "user",
+            Role::Assistant => "assistant",
+        }
+    }
+}
+
+/// A single message of the conversation: who said it and what.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ChatTurn {
+    pub role: Role,
+    pub content: String,
+}
+
+/// Client for an OpenAI-compatible `/chat/completions` endpoint.
+#[derive(Clone)]
+pub struct AiClient {
+    http: reqwest::Client,
+    endpoint: String,
+    api_key: String,
+    model: String,
+    vision_model: Option<String>,
+}
+
+impl fmt::Debug for AiClient {
+    /// Formats the client for debugging, deliberately omitting the API key.
+    ///
+    /// Inputs: formatter. Output: formatting result.
+    /// Side effects: writes to the formatter only.
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        formatter
+            .debug_struct("AiClient")
+            .field("endpoint", &self.endpoint)
+            .field("model", &self.model)
+            .field("vision_model", &self.vision_model)
+            .finish_non_exhaustive()
+    }
+}
+
+/// An image attached to a vision request.
+#[derive(Clone)]
+pub struct ImageInput {
+    pub mime: String,
+    pub bytes: Vec<u8>,
+}
+
+impl fmt::Debug for ImageInput {
+    /// Formats the image for debugging, omitting the (large, private) bytes.
+    ///
+    /// Inputs: formatter. Output: formatting result (MIME type and byte length).
+    /// Side effects: writes to the formatter only.
+    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+        formatter
+            .debug_struct("ImageInput")
+            .field("mime", &self.mime)
+            .field("len", &self.bytes.len())
+            .finish_non_exhaustive()
+    }
+}
+
+impl AiClient {
+    /// Builds a client from the application configuration.
+    ///
+    /// Inputs: `config` (API key, base URL and model are read from it).
+    /// Output: the client, or an error if the API key is missing or the HTTP
+    /// client cannot be built.
+    /// Side effects: none (no network traffic until `complete` is called).
+    pub fn new(config: &Config) -> anyhow::Result<AiClient> {
+        let api_key = config
+            .openai_api_key
+            .clone()
+            .ok_or_else(|| anyhow!(MISSING_API_KEY_MESSAGE))?;
+        let http = reqwest::Client::builder()
+            .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS))
+            .build()
+            .context("impossibile creare il client HTTP")?;
+        let endpoint = format!(
+            "{}{}",
+            config.openai_base_url.trim_end_matches('/'),
+            CHAT_COMPLETIONS_PATH
+        );
+
+        Ok(AiClient {
+            http,
+            endpoint,
+            api_key,
+            model: config.openai_model.clone(),
+            vision_model: config.openai_vision_model.clone(),
+        })
+    }
+
+    /// Requests a completion for the given conversation.
+    ///
+    /// Inputs: `turns`, the ordered conversation (system prompt included).
+    /// Output: the first choice's message content, trimmed; an error on
+    /// transport failure, non-2xx status (status code and truncated body, never
+    /// the key), malformed JSON or empty reply.
+    /// Side effects: performs one HTTPS POST request.
+    pub async fn complete(&self, turns: &[ChatTurn]) -> anyhow::Result<String> {
+        self.send_body(&build_request_body(&self.model, turns)).await
+    }
+
+    /// Tells whether a vision model is configured.
+    ///
+    /// Inputs: the client. Output: `true` if `complete_with_image` can be used.
+    /// Side effects: none.
+    pub fn has_vision(&self) -> bool {
+        self.vision_model.is_some()
+    }
+
+    /// Requests a completion for the conversation with an attached image.
+    ///
+    /// Inputs: `turns`, the ordered conversation; `image`, attached to the last
+    /// user turn (or to a new user turn if there is none).
+    /// Output: the reply text; an error if no vision model is configured, the
+    /// image is empty or larger than `MAX_IMAGE_BYTES`, or on any failure
+    /// described in `complete`. Errors never contain the image data.
+    /// Side effects: performs one HTTPS POST request.
+    pub async fn complete_with_image(
+        &self,
+        turns: &[ChatTurn],
+        image: &ImageInput,
+    ) -> anyhow::Result<String> {
+        let model = self
+            .vision_model
+            .as_deref()
+            .ok_or_else(|| anyhow!(MISSING_VISION_MODEL_MESSAGE))?;
+        validate_image(image)?;
+
+        self.send_body(&build_vision_request_body(model, turns, image))
+            .await
+    }
+
+    /// Sends a prepared request body and extracts the reply text.
+    ///
+    /// Inputs: `request_body`, the JSON payload for the chat completions endpoint.
+    /// Output: the first choice's message content, trimmed; an error on
+    /// transport failure, non-2xx status (status code and truncated body, never
+    /// the key), malformed JSON or empty reply.
+    /// Side effects: performs one HTTPS POST request.
+    async fn send_body(&self, request_body: &Value) -> anyhow::Result<String> {
+        let response = self
+            .http
+            .post(&self.endpoint)
+            .bearer_auth(&self.api_key)
+            .json(request_body)
+            .send()
+            .await
+            .context("richiesta al servizio AI fallita")?;
+        let status = response.status();
+        let text = response
+            .text()
+            .await
+            .context("lettura della risposta del servizio AI fallita")?;
+
+        if !status.is_success() {
+            bail!("servizio AI ha risposto {status}: {}", truncate_chars(&text));
+        }
+        let body: Value = serde_json::from_str(&text).with_context(|| {
+            format!(
+                "risposta del servizio AI non valida: {}",
+                truncate_chars(&text)
+            )
+        })?;
+
+        parse_reply(&body)
+    }
+}
+
+/// Builds the JSON request body for the chat completions endpoint.
+///
+/// Inputs: `model` name and the conversation `turns`.
+/// Output: `{"model": ..., "messages": [{"role": ..., "content": ...}]}`.
+/// Side effects: none.
+fn build_request_body(model: &str, turns: &[ChatTurn]) -> Value {
+    json!({ "model": model, "messages": build_messages(turns) })
+}
+
+/// Converts conversation turns into wire-format messages.
+///
+/// Inputs: the conversation `turns`.
+/// Output: one `{"role": ..., "content": ...}` JSON object per turn.
+/// Side effects: none.
+fn build_messages(turns: &[ChatTurn]) -> Vec<Value> {
+    turns
+        .iter()
+        .map(|turn| json!({ "role": turn.role.as_wire_str(), "content": turn.content }))
+        .collect()
+}
+
+/// Builds the request body for a vision completion.
+///
+/// Inputs: the vision `model` name, the conversation `turns` and the `image`.
+/// Output: like `build_request_body`, but the last user turn's content becomes
+/// `[text part, image_url part]`; with no user turn, a user message holding
+/// only the image part is appended.
+/// Side effects: none.
+fn build_vision_request_body(model: &str, turns: &[ChatTurn], image: &ImageInput) -> Value {
+    let mut messages = build_messages(turns);
+    let image_part = build_image_part(image);
+
+    match turns.iter().rposition(|turn| turn.role == Role::User) {
+        Some(index) => {
+            let text_part = json!({ "type": TEXT_PART_TYPE, "text": turns[index].content });
+            messages[index]["content"] = json!([text_part, image_part]);
+        }
+        None => messages.push(json!({
+            "role": Role::User.as_wire_str(),
+            "content": [image_part],
+        })),
+    }
+
+    json!({ "model": model, "messages": messages })
+}
+
+/// Builds the `image_url` content part carrying the image as a data URL.
+///
+/// Inputs: the `image`.
+/// Output: `{"type":"image_url","image_url":{"url":"data:<mime>;base64,<b64>"}}`.
+/// Side effects: none.
+fn build_image_part(image: &ImageInput) -> Value {
+    let encoded = BASE64_STANDARD.encode(&image.bytes);
+
+    json!({
+        "type": IMAGE_PART_TYPE,
+        "image_url": { "url": format!("data:{};base64,{encoded}", image.mime) },
+    })
+}
+
+/// Checks that the image is non-empty and within `MAX_IMAGE_BYTES`.
+///
+/// Inputs: the `image`.
+/// Output: `Ok(())`, or an error mentioning the size only (never the data).
+/// Side effects: none.
+fn validate_image(image: &ImageInput) -> anyhow::Result<()> {
+    if image.bytes.is_empty() {
+        bail!(EMPTY_IMAGE_MESSAGE);
+    }
+    if image.bytes.len() > MAX_IMAGE_BYTES {
+        bail!(
+            "immagine troppo grande: {} byte (massimo {MAX_IMAGE_BYTES})",
+            image.bytes.len()
+        );
+    }
+
+    Ok(())
+}
+
+/// Extracts the reply text from a chat completions response body.
+///
+/// Inputs: the parsed JSON `body`.
+/// Output: the first choice's message content, trimmed; an error if there are
+/// no choices, the content is missing, or it is empty after trimming.
+/// Side effects: none.
+fn parse_reply(body: &Value) -> anyhow::Result<String> {
+    let first_choice = body
+        .get("choices")
+        .and_then(Value::as_array)
+        .and_then(|choices| choices.first())
+        .ok_or_else(|| anyhow!("risposta del servizio AI senza choices"))?;
+    let content = first_choice
+        .pointer("/message/content")
+        .and_then(Value::as_str)
+        .map(str::trim)
+        .unwrap_or_default();
+
+    if content.is_empty() {
+        bail!("risposta del servizio AI vuota");
+    }
+
+    Ok(content.to_string())
+}
+
+/// Truncates text to `ERROR_BODY_MAX_CHARS` characters for error messages.
+///
+/// Inputs: `text`. Output: the text, cut on a character boundary.
+/// Side effects: none.
+fn truncate_chars(text: &str) -> String {
+    text.chars().take(ERROR_BODY_MAX_CHARS).collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use std::path::PathBuf;
+
+    use super::*;
+
+    const TEST_API_KEY: &str = "sk-test-secret-key";
+    const TEST_MODEL: &str = "test-model";
+
+    /// Builds a test configuration with the given API key.
+    fn test_config(api_key: Option<&str>) -> Config {
+        Config {
+            openai_api_key: api_key.map(str::to_string),
+            openai_base_url: "https://example.test/v1".to_string(),
+            openai_model: TEST_MODEL.to_string(),
+            system_prompt: Some("prompt".to_string()),
+            data_dir: PathBuf::from("data"),
+            openai_vision_model: None,
+        }
+    }
+
+    /// Builds a client, optionally with a vision model configured.
+    fn test_client(vision_model: Option<&str>) -> AiClient {
+        let mut config = test_config(Some(TEST_API_KEY));
+        config.openai_vision_model = vision_model.map(str::to_string);
+
+        AiClient::new(&config).unwrap()
+    }
+
+    /// Builds a small test image.
+    fn test_image(bytes: Vec<u8>) -> ImageInput {
+        ImageInput { mime: "image/png".to_string(), bytes }
+    }
+
+    /// Polls a future that must complete without suspending (no I/O involved).
+    fn poll_ready<T>(future: impl std::future::Future<Output = T>) -> T {
+        let mut future = std::pin::pin!(future);
+        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
+
+        match future.as_mut().poll(&mut context) {
+            std::task::Poll::Ready(value) => value,
+            std::task::Poll::Pending => panic!("future suspended unexpectedly"),
+        }
+    }
+
+    #[test]
+    fn vision_body_replaces_last_user_turn_with_text_and_image_parts() {
+        let turns = [
+            ChatTurn { role: Role::System, content: "be nice".into() },
+            ChatTurn { role: Role::User, content: "first".into() },
+            ChatTurn { role: Role::Assistant, content: "ok".into() },
+            ChatTurn { role: Role::User, content: "look".into() },
+        ];
+        let image = test_image(vec![1, 2, 3]);
+
+        let body = build_vision_request_body("vision-model", &turns, &image);
+
+        assert_eq!(
+            body,
+            json!({
+                "model": "vision-model",
+                "messages": [
+                    {"role": "system", "content": "be nice"},
+                    {"role": "user", "content": "first"},
+                    {"role": "assistant", "content": "ok"},
+                    {"role": "user", "content": [
+                        {"type": "text", "text": "look"},
+                        {"type": "image_url", "image_url": {"url": "data:image/png;base64,AQID"}},
+                    ]},
+                ]
+            })
+        );
+    }
+
+    #[test]
+    fn vision_body_appends_image_only_user_turn_when_none_present() {
+        let turns = [ChatTurn { role: Role::System, content: "be nice".into() }];
+
+        let body = build_vision_request_body("vision-model", &turns, &test_image(vec![1, 2, 3]));
+
+        assert_eq!(
+            body["messages"][1],
+            json!({"role": "user", "content": [
+                {"type": "image_url", "image_url": {"url": "data:image/png;base64,AQID"}},
+            ]})
+        );
+        assert_eq!(body["messages"].as_array().unwrap().len(), 2);
+    }
+
+    #[test]
+    fn complete_with_image_fails_without_vision_model() {
+        let client = test_client(None);
+
+        let error = poll_ready(client.complete_with_image(&[], &test_image(vec![1]))).unwrap_err();
+
+        assert_eq!(error.to_string(), MISSING_VISION_MODEL_MESSAGE);
+    }
+
+    #[test]
+    fn complete_with_image_fails_on_oversize_image_without_leaking_data() {
+        let client = test_client(Some("vision-model"));
+        let image = test_image(vec![7; MAX_IMAGE_BYTES + 1]);
+
+        let error = poll_ready(client.complete_with_image(&[], &image)).unwrap_err();
+
+        assert!(error.to_string().contains(&(MAX_IMAGE_BYTES + 1).to_string()));
+        assert!(!error.to_string().contains("base64"));
+    }
+
+    #[test]
+    fn complete_with_image_fails_on_empty_image() {
+        let client = test_client(Some("vision-model"));
+
+        let error = poll_ready(client.complete_with_image(&[], &test_image(Vec::new()))).unwrap_err();
+
+        assert_eq!(error.to_string(), EMPTY_IMAGE_MESSAGE);
+    }
+
+    #[test]
+    fn validate_image_accepts_exactly_max_size() {
+        assert!(validate_image(&test_image(vec![0; MAX_IMAGE_BYTES])).is_ok());
+    }
+
+    #[test]
+    fn has_vision_reflects_configuration() {
+        assert!(test_client(Some("vision-model")).has_vision());
+        assert!(!test_client(None).has_vision());
+    }
+
+    #[test]
+    fn debug_hides_key_and_image_bytes() {
+        let client = test_client(Some("vision-model"));
+        let image = test_image(vec![1, 2, 3]);
+
+        assert!(format!("{client:?}").contains("vision-model"));
+        assert!(!format!("{client:?}").contains(TEST_API_KEY));
+        let debug = format!("{image:?}");
+        assert!(debug.contains("image/png") && debug.contains('3') && !debug.contains("AQID"));
+    }
+
+    #[test]
+    fn build_request_body_has_model_and_role_messages() {
+        let turns = [
+            ChatTurn { role: Role::System, content: "be nice".into() },
+            ChatTurn { role: Role::User, content: "hi".into() },
+            ChatTurn { role: Role::Assistant, content: "hello".into() },
+        ];
+
+        let body = build_request_body(TEST_MODEL, &turns);
+
+        assert_eq!(
+            body,
+            json!({
+                "model": TEST_MODEL,
+                "messages": [
+                    {"role": "system", "content": "be nice"},
+                    {"role": "user", "content": "hi"},
+                    {"role": "assistant", "content": "hello"},
+                ]
+            })
+        );
+    }
+
+    #[test]
+    fn parse_reply_returns_trimmed_first_choice() {
+        let body = json!({"choices": [
+            {"message": {"content": "  ciao \n"}},
+            {"message": {"content": "other"}},
+        ]});
+
+        assert_eq!(parse_reply(&body).unwrap(), "ciao");
+    }
+
+    #[test]
+    fn parse_reply_fails_on_missing_or_empty_choices() {
+        assert!(parse_reply(&json!({})).is_err());
+        assert!(parse_reply(&json!({"choices": []})).is_err());
+    }
+
+    #[test]
+    fn parse_reply_fails_on_empty_or_missing_content() {
+        assert!(parse_reply(&json!({"choices": [{"message": {"content": "  "}}]})).is_err());
+        assert!(parse_reply(&json!({"choices": [{"message": {"content": null}}]})).is_err());
+        assert!(parse_reply(&json!({"choices": [{}]})).is_err());
+    }
+
+    #[test]
+    fn new_fails_without_api_key() {
+        let error = AiClient::new(&test_config(None)).unwrap_err();
+
+        assert_eq!(error.to_string(), MISSING_API_KEY_MESSAGE);
+    }
+
+    #[test]
+    fn new_builds_endpoint_and_debug_hides_key() {
+        let client = AiClient::new(&test_config(Some(TEST_API_KEY))).unwrap();
+
+        assert_eq!(client.endpoint, "https://example.test/v1/chat/completions");
+        assert!(!format!("{client:?}").contains(TEST_API_KEY));
+    }
+
+    #[test]
+    fn truncate_chars_limits_length_on_char_boundary() {
+        let long = "è".repeat(ERROR_BODY_MAX_CHARS + 10);
+
+        assert_eq!(truncate_chars(&long).chars().count(), ERROR_BODY_MAX_CHARS);
+    }
+}

+ 837 - 0
src/app.rs

@@ -0,0 +1,837 @@
+//! Application state, key handling and routing of events between UI, WhatsApp, history and AI.
+
+use anyhow::Result;
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use tokio::sync::mpsc::UnboundedSender;
+
+use crate::ai::{AiClient, ChatTurn, ImageInput};
+use crate::autoreply::{CONTEXT_MESSAGES, ReplyGate, TurnContext, build_turns, is_private_chat};
+use crate::config::Config;
+use crate::history::{ChatSummary, Contact, History, StoredMessage};
+use crate::whatsapp::{ImageRef, WaEvent, WaHandle};
+
+/// Maximum number of messages of the open chat kept in `App::messages`.
+pub const MESSAGES_SHOWN: usize = 200;
+
+/// Key code of the function key that toggles the list mode.
+const LIST_MODE_FUNCTION_KEY: u8 = 2;
+/// Character that, combined with Ctrl, quits the application.
+const QUIT_CHAR: char = 'c';
+/// Character that, combined with Ctrl, toggles the autoreply of the open chat.
+const AUTOREPLY_CHAR: char = 'a';
+/// Sender jid stored for messages written by the user (own account).
+const OWN_SENDER_JID: &str = "";
+
+/// Status shown once the WhatsApp session is connected.
+const STATUS_CONNECTED: &str = "Connesso";
+/// Status shown when the WhatsApp session was unlinked from the phone.
+const STATUS_LOGGED_OUT: &str = "Sessione scollegata";
+/// Status shown when the autoreply cannot be enabled without an AI client.
+const STATUS_AI_NOT_CONFIGURED: &str =
+    "AI non configurata: imposta openai_api_key e system_prompt in config.toml";
+/// Prefix of the status shown when the AI client cannot be built.
+const STATUS_AI_UNAVAILABLE_PREFIX: &str = "AI non disponibile";
+/// Prefix of the status shown when the AI request fails.
+const STATUS_AI_ERROR_PREFIX: &str = "Errore AI";
+/// Status shown when an AI reply is dropped because the autoreply was turned off meanwhile.
+const STATUS_AI_REPLY_DISCARDED: &str = "Risposta AI scartata: autoreply disattivato";
+/// Status shown when the autoreply is requested on a non-private chat.
+const STATUS_AUTOREPLY_PRIVATE_ONLY: &str = "Autoreply disponibile solo nelle chat private";
+/// Prefix of the AI error shown when the image to describe cannot be downloaded.
+const STATUS_IMAGE_DOWNLOAD_PREFIX: &str = "Download immagine fallito";
+/// Prefix of the courtesy fallback error appended to the vision error when both AI calls fail.
+const STATUS_FALLBACK_ERROR_PREFIX: &str = "Fallback cortesia fallito";
+/// Prefix of the status shown when the history lookup fails.
+const STATUS_HISTORY_ERROR_PREFIX: &str = "Errore storico";
+/// Prefix of the status shown when a message cannot be sent.
+const STATUS_SEND_FAILED_PREFIX: &str = "Invio fallito";
+
+/// Event delivered to `App::handle_event` by the main loop and by background tasks.
+#[derive(Debug)]
+pub enum AppEvent {
+    /// Keyboard event (only `Press` events are sent by the main loop).
+    Key(KeyEvent),
+    /// Terminal event that needs no state change (e.g. resize): the UI is simply redrawn.
+    Redraw,
+    /// Event coming from the WhatsApp layer.
+    Wa(WaEvent),
+    /// Outcome of an AI completion requested for `chat_jid`.
+    AiReply {
+        chat_jid: String,
+        result: Result<String, String>,
+    },
+    /// Outcome of a send request; `Ok` carries the message id.
+    Sent {
+        chat_jid: String,
+        text: String,
+        is_ai: bool,
+        result: Result<String, String>,
+    },
+}
+
+/// Screen currently displayed.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Screen {
+    Connecting,
+    Pairing,
+    Main,
+}
+
+/// Widget that receives the keyboard input on the main screen.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Focus {
+    List,
+    Input,
+}
+
+/// Content of the left list.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ListMode {
+    Chats,
+    Contacts,
+}
+
+/// Everything `App::new` needs from the outside.
+pub struct AppDeps {
+    pub config: Config,
+    pub history: History,
+    pub wa: WaHandle,
+    pub events: UnboundedSender<AppEvent>,
+}
+
+/// An AI request that also carries the image to describe.
+struct VisionRequest {
+    chat_jid: String,
+    /// Conversation sent together with the image.
+    turns: Vec<ChatTurn>,
+    /// Text-only (courtesy) conversation used when the vision path fails.
+    fallback_turns: Vec<ChatTurn>,
+    image: ImageRef,
+}
+
+/// A text to be sent to a chat by a background task.
+struct OutgoingMessage {
+    chat_jid: String,
+    text: String,
+    is_ai: bool,
+}
+
+/// Whole state of the application; the UI only reads its public fields.
+pub struct App {
+    pub screen: Screen,
+    pub qr_code: Option<String>,
+    pub focus: Focus,
+    pub list_mode: ListMode,
+    pub chats: Vec<ChatSummary>,
+    pub contacts: Vec<Contact>,
+    /// Index in the active list.
+    pub selected: usize,
+    /// Jid of the open chat.
+    pub open_chat: Option<String>,
+    pub open_chat_name: String,
+    pub open_chat_autoreply: bool,
+    /// Open chat messages, ascending, last `MESSAGES_SHOWN`.
+    pub messages: Vec<StoredMessage>,
+    pub input: String,
+    /// Status bar text (errors, info).
+    pub status: String,
+    pub should_quit: bool,
+    history: History,
+    config: Config,
+    ai: Option<AiClient>,
+    gate: ReplyGate,
+    /// Whether the gate has been given a non-empty set of own JIDs.
+    own_jids_known: bool,
+    wa: WaHandle,
+    events: UnboundedSender<AppEvent>,
+}
+
+impl App {
+    /// Purpose: build the initial state and load chats and contacts from the history.
+    /// Inputs: `deps`, the configuration, history, WhatsApp handle and event sender.
+    /// Output: the `App` on the connecting screen, or an error if the history cannot be read.
+    /// Side effects: reads the history; an AI client failure is reported in `status`, not as error.
+    pub fn new(deps: AppDeps) -> Result<App> {
+        let AppDeps { config, history, wa, events } = deps;
+        let (ai, status) = build_ai(&config);
+        let chats = history.list_chats()?;
+        let contacts = history.list_contacts()?;
+
+        Ok(App {
+            screen: Screen::Connecting,
+            qr_code: None,
+            focus: Focus::List,
+            list_mode: ListMode::Chats,
+            chats,
+            contacts,
+            selected: 0,
+            open_chat: None,
+            open_chat_name: String::new(),
+            open_chat_autoreply: false,
+            messages: Vec::new(),
+            input: String::new(),
+            status,
+            should_quit: false,
+            history,
+            config,
+            ai,
+            gate: ReplyGate::new(now_unix()),
+            own_jids_known: false,
+            wa,
+            events,
+        })
+    }
+
+    /// Purpose: apply one event to the state, spawning send/AI tasks when needed.
+    /// Inputs: `event`, the event to route.
+    /// Output: `Ok(())`, or an error only for fatal history (storage) failures.
+    /// Side effects: mutates the state, writes the history, spawns tokio tasks.
+    pub fn handle_event(&mut self, event: AppEvent) -> Result<()> {
+        match event {
+            AppEvent::Key(key) => self.handle_key(key),
+            AppEvent::Redraw => Ok(()),
+            AppEvent::Wa(wa_event) => self.handle_wa_event(wa_event),
+            AppEvent::AiReply { chat_jid, result } => {
+                self.handle_ai_reply(chat_jid, result);
+                Ok(())
+            }
+            AppEvent::Sent { chat_jid, text, is_ai, result } => {
+                self.handle_sent(OutgoingMessage { chat_jid, text, is_ai }, result)
+            }
+        }
+    }
+
+    /// Purpose: route a WhatsApp layer event.
+    /// Inputs: `event`, the event from the WhatsApp layer.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: updates screen/status, writes the history, may spawn an AI request.
+    fn handle_wa_event(&mut self, event: WaEvent) -> Result<()> {
+        match event {
+            WaEvent::Qr(code) => {
+                self.screen = Screen::Pairing;
+                self.qr_code = Some(code);
+            }
+            WaEvent::Connected => {
+                self.screen = Screen::Main;
+                self.qr_code = None;
+                self.status = STATUS_CONNECTED.to_owned();
+                self.refresh_own_jids();
+            }
+            WaEvent::Disconnected(text) | WaEvent::Error(text) => self.status = text,
+            WaEvent::LoggedOut => {
+                self.screen = Screen::Connecting;
+                self.qr_code = None;
+                self.status = STATUS_LOGGED_OUT.to_owned();
+            }
+            WaEvent::Contact(contact) => self.on_contact(&contact)?,
+            WaEvent::Message { message, image } => self.on_message(&message, image)?,
+        }
+        Ok(())
+    }
+
+    /// Purpose: store a contact and refresh the lists and the open chat name.
+    /// Inputs: `contact`, the contact received from WhatsApp.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: writes the history; reloads chats and contacts.
+    fn on_contact(&mut self, contact: &Contact) -> Result<()> {
+        self.history.upsert_contact(contact)?;
+        self.reload_contacts()?;
+        self.reload_chats()?;
+        if let Some(jid) = &self.open_chat {
+            self.open_chat_name = self.history.display_name(jid)?;
+        }
+        Ok(())
+    }
+
+    /// Purpose: store an incoming/synced message and, if new, refresh views and maybe autoreply.
+    /// Inputs: `message`, the message received from WhatsApp; `image`, the reference to its
+    /// image when it is a live image message.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: writes the history; reloads chats/messages; may spawn an AI request.
+    fn on_message(&mut self, message: &StoredMessage, image: Option<ImageRef>) -> Result<()> {
+        if !self.history.save_message(message)? {
+            return Ok(());
+        }
+        self.reload_chats()?;
+        if self.open_chat.as_deref() == Some(message.chat_jid.as_str()) {
+            self.reload_messages()?;
+        }
+        self.maybe_autoreply(message, image)
+    }
+
+    /// Purpose: start an AI completion if the gate allows an autoreply to `message`.
+    /// Inputs: `message`, the newly stored message; `image`, the reference to its image, if any.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: records the reply in the gate; spawns a task that sends `AiReply`. The image
+    /// is downloaded and sent to the model only when a vision model is configured.
+    fn maybe_autoreply(&mut self, message: &StoredMessage, image: Option<ImageRef>) -> Result<()> {
+        let (Some(ai), Some(prompt)) = (self.ai.clone(), self.config.system_prompt.clone()) else {
+            return Ok(());
+        };
+        if !self.own_jids_known {
+            self.refresh_own_jids();
+        }
+        let now = now_unix();
+        let enabled = self.history.is_autoreply(&message.chat_jid)?;
+        if !self.gate.should_reply(message, enabled, now) {
+            return Ok(());
+        }
+        self.gate.record_reply(&message.chat_jid, now);
+        let context = self.history.recent_messages(&message.chat_jid, CONTEXT_MESSAGES)?;
+        let image = image.filter(|_| vision_applies(true, ai.has_vision()));
+        self.spawn_reply(ai, &prompt, &message.chat_jid, &context, image);
+        Ok(())
+    }
+
+    /// Purpose: build the conversation turns and spawn the matching AI task.
+    /// Inputs: `ai`, the client; `prompt`, the configured system prompt; `chat_jid`, the chat
+    /// being answered; `context`, the recent messages of the chat; `image`, the image to describe
+    /// when the vision path applies.
+    /// Output: none.
+    /// Side effects: spawns a tokio task that sends `AiReply`. With an image, the request also
+    /// carries the no-vision courtesy turns used when the vision path fails.
+    fn spawn_reply(
+        &self,
+        ai: AiClient,
+        prompt: &str,
+        chat_jid: &str,
+        context: &[StoredMessage],
+        image: Option<ImageRef>,
+    ) {
+        let self_chat = self.gate.is_self_chat(chat_jid);
+        let chat_jid = chat_jid.to_owned();
+        match image {
+            Some(image) => {
+                let turns = build_turns(prompt, context, TurnContext { self_chat, vision: true });
+                let fallback_turns =
+                    build_turns(prompt, context, TurnContext { self_chat, vision: false });
+                let request = VisionRequest { chat_jid, turns, fallback_turns, image };
+                spawn_vision_request(ai, self.wa.clone(), self.events.clone(), request);
+            }
+            None => {
+                let turns = build_turns(prompt, context, TurnContext { self_chat, vision: false });
+                spawn_ai_request(ai, self.events.clone(), chat_jid, turns);
+            }
+        }
+    }
+
+    /// Purpose: give the gate the account's own JIDs so it can recognise the self-chat.
+    /// Inputs: none (reads the JIDs from the WhatsApp handle).
+    /// Output: none.
+    /// Side effects: replaces the gate's own JIDs when the handle reports any (it reports none
+    /// while not paired, in which case the gate is left untouched and the next call retries).
+    fn refresh_own_jids(&mut self) {
+        let jids = self.wa.own_jids();
+        if jids.is_empty() {
+            return;
+        }
+        self.gate.set_own_jids(jids);
+        self.own_jids_known = true;
+    }
+
+    /// Purpose: handle the outcome of an AI completion.
+    /// Inputs: `chat_jid`, the chat to answer; `result`, the generated text or an error text.
+    /// Output: none.
+    /// Side effects: on success, if the autoreply is still enabled, spawns a send task; if it was
+    /// disabled meanwhile or the lookup fails, nothing is sent and the status explains why; on
+    /// failure sets the error status.
+    fn handle_ai_reply(&mut self, chat_jid: String, result: Result<String, String>) {
+        match result {
+            Ok(text) => self.send_ai_reply_if_enabled(chat_jid, text),
+            Err(err) => self.status = format!("{STATUS_AI_ERROR_PREFIX}: {err}"),
+        }
+    }
+
+    /// Purpose: send a generated AI reply only if the chat still has the autoreply enabled.
+    /// Inputs: `chat_jid`, the chat to answer; `text`, the generated reply.
+    /// Output: none.
+    /// Side effects: spawns a send task when enabled; otherwise sets the discard or history
+    /// error status and sends nothing.
+    fn send_ai_reply_if_enabled(&mut self, chat_jid: String, text: String) {
+        match self.history.is_autoreply(&chat_jid) {
+            Ok(true) => {
+                let outgoing = OutgoingMessage { chat_jid, text, is_ai: true };
+                spawn_send(self.wa.clone(), self.events.clone(), outgoing);
+            }
+            Ok(false) => self.status = STATUS_AI_REPLY_DISCARDED.to_owned(),
+            Err(err) => self.status = format!("{STATUS_HISTORY_ERROR_PREFIX}: {err:#}"),
+        }
+    }
+
+    /// Purpose: handle the outcome of a send request.
+    /// Inputs: `sent`, the message that was sent; `result`, the message id or an error text.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: on success stores the message and reloads views; on failure sets the status.
+    fn handle_sent(&mut self, sent: OutgoingMessage, result: Result<String, String>) -> Result<()> {
+        let message_id = match result {
+            Ok(id) => id,
+            Err(err) => {
+                self.status = format!("{STATUS_SEND_FAILED_PREFIX}: {err}");
+                return Ok(());
+            }
+        };
+        let stored = StoredMessage {
+            message_id,
+            chat_jid: sent.chat_jid,
+            sender_jid: OWN_SENDER_JID.to_owned(),
+            sender_name: None,
+            text: sent.text,
+            timestamp: now_unix(),
+            from_me: true,
+            is_ai: sent.is_ai,
+        };
+        self.history.save_message(&stored)?;
+        self.reload_chats()?;
+        if self.open_chat.as_deref() == Some(stored.chat_jid.as_str()) {
+            self.reload_messages()?;
+        }
+        Ok(())
+    }
+
+    /// Purpose: handle a key press on any screen.
+    /// Inputs: `key`, the pressed key.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: may quit, change focus/mode/selection, edit the input, send or open chats.
+    fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
+        let ctrl = is_command(key.modifiers);
+        if ctrl && key.code == KeyCode::Char(QUIT_CHAR) {
+            self.should_quit = true;
+            return Ok(());
+        }
+        if self.screen != Screen::Main {
+            return Ok(());
+        }
+        if ctrl {
+            return match key.code {
+                KeyCode::Char(AUTOREPLY_CHAR) => self.toggle_autoreply(),
+                _ => Ok(()),
+            };
+        }
+        match (key.code, self.focus) {
+            (KeyCode::Tab, _) => self.focus = toggled_focus(self.focus),
+            (KeyCode::F(LIST_MODE_FUNCTION_KEY), _) => self.toggle_list_mode(),
+            (code, Focus::List) => return self.handle_list_key(code),
+            (code, Focus::Input) => self.handle_input_key(code),
+        }
+        Ok(())
+    }
+
+    /// Purpose: handle a key while the list has the focus.
+    /// Inputs: `code`, the key code.
+    /// Output: `Ok(())`, or a history error when opening a chat.
+    /// Side effects: moves the selection or opens the selected chat.
+    fn handle_list_key(&mut self, code: KeyCode) -> Result<()> {
+        match code {
+            KeyCode::Up => self.selected = step_selection(self.selected, self.active_len(), -1),
+            KeyCode::Down => self.selected = step_selection(self.selected, self.active_len(), 1),
+            KeyCode::Enter => return self.open_selected(),
+            _ => {}
+        }
+        Ok(())
+    }
+
+    /// Purpose: handle a key while the input line has the focus.
+    /// Inputs: `code`, the key code.
+    /// Output: none.
+    /// Side effects: edits the input, returns focus to the list or spawns a send task.
+    fn handle_input_key(&mut self, code: KeyCode) {
+        match code {
+            KeyCode::Char(c) => self.input.push(c),
+            KeyCode::Backspace => {
+                self.input.pop();
+            }
+            KeyCode::Esc => self.focus = Focus::List,
+            KeyCode::Enter => self.submit_input(),
+            _ => {}
+        }
+    }
+
+    /// Purpose: send the typed text to the open chat.
+    /// Inputs: none (uses `input` and `open_chat`).
+    /// Output: none.
+    /// Side effects: clears the input and spawns a send task; does nothing if blank or no chat.
+    fn submit_input(&mut self) {
+        let text = self.input.trim().to_owned();
+        let Some(chat_jid) = self.open_chat.clone().filter(|_| !text.is_empty()) else {
+            return;
+        };
+        self.input.clear();
+        let outgoing = OutgoingMessage { chat_jid, text, is_ai: false };
+        spawn_send(self.wa.clone(), self.events.clone(), outgoing);
+    }
+
+    /// Purpose: switch between chat list and contact list.
+    /// Inputs: none.
+    /// Output: none.
+    /// Side effects: changes `list_mode` and resets the selection to the first row.
+    fn toggle_list_mode(&mut self) {
+        self.list_mode = toggled_list_mode(self.list_mode);
+        self.selected = 0;
+    }
+
+    /// Purpose: toggle the AI autoreply of the open chat.
+    /// Inputs: none.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: writes the history, updates `status`, reloads chats; refuses to enable
+    /// without an AI client or outside private chats (disabling is always allowed).
+    fn toggle_autoreply(&mut self) -> Result<()> {
+        let Some(jid) = self.open_chat.clone() else {
+            return Ok(());
+        };
+        let enable = !self.open_chat_autoreply;
+        if enable && let Err(refusal) = can_enable_autoreply(&jid, self.ai.is_some()) {
+            self.status = refusal.to_owned();
+            return Ok(());
+        }
+        self.history.set_autoreply(&jid, enable)?;
+        self.open_chat_autoreply = enable;
+        let state = if enable { "ON" } else { "OFF" };
+        self.status = format!("Autoreply {state} per {}", self.open_chat_name);
+        self.reload_chats()
+    }
+
+    /// Purpose: open the chat or contact selected in the active list.
+    /// Inputs: none.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: loads name, autoreply flag and messages; moves the focus to the input.
+    fn open_selected(&mut self) -> Result<()> {
+        let jid = match self.list_mode {
+            ListMode::Chats => self.chats.get(self.selected).map(|chat| chat.jid.clone()),
+            ListMode::Contacts => self.contacts.get(self.selected).map(|c| c.jid.clone()),
+        };
+        let Some(jid) = jid else {
+            return Ok(());
+        };
+        self.open_chat_name = self.history.display_name(&jid)?;
+        self.open_chat_autoreply = self.history.is_autoreply(&jid)?;
+        self.messages = self.history.recent_messages(&jid, MESSAGES_SHOWN)?;
+        self.open_chat = Some(jid);
+        self.focus = Focus::Input;
+        Ok(())
+    }
+
+    /// Purpose: number of rows of the active list.
+    /// Inputs: none.
+    /// Output: length of the chat or contact list according to `list_mode`.
+    /// Side effects: none.
+    fn active_len(&self) -> usize {
+        match self.list_mode {
+            ListMode::Chats => self.chats.len(),
+            ListMode::Contacts => self.contacts.len(),
+        }
+    }
+
+    /// Purpose: reload the chat list from the history.
+    /// Inputs: none.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: replaces `chats` and keeps the selection inside the active list.
+    fn reload_chats(&mut self) -> Result<()> {
+        self.chats = self.history.list_chats()?;
+        self.selected = clamped_selection(self.selected, self.active_len());
+        Ok(())
+    }
+
+    /// Purpose: reload the contact list from the history.
+    /// Inputs: none.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: replaces `contacts` and keeps the selection inside the active list.
+    fn reload_contacts(&mut self) -> Result<()> {
+        self.contacts = self.history.list_contacts()?;
+        self.selected = clamped_selection(self.selected, self.active_len());
+        Ok(())
+    }
+
+    /// Purpose: reload the messages of the open chat from the history.
+    /// Inputs: none.
+    /// Output: `Ok(())`, or a history error; nothing happens if no chat is open.
+    /// Side effects: replaces `messages` with the last `MESSAGES_SHOWN` ones.
+    fn reload_messages(&mut self) -> Result<()> {
+        if let Some(jid) = &self.open_chat {
+            self.messages = self.history.recent_messages(jid, MESSAGES_SHOWN)?;
+        }
+        Ok(())
+    }
+}
+
+/// Purpose: current time as unix seconds.
+/// Inputs: none.
+/// Output: seconds since the unix epoch (UTC).
+/// Side effects: reads the system clock.
+fn now_unix() -> i64 {
+    chrono::Utc::now().timestamp()
+}
+
+/// Purpose: keep a selection index inside a list.
+/// Inputs: `selected`, the current index; `len`, the list length.
+/// Output: `selected` limited to the last row, or 0 for an empty list.
+/// Side effects: none.
+fn clamped_selection(selected: usize, len: usize) -> usize {
+    selected.min(len.saturating_sub(1))
+}
+
+/// Purpose: move a selection index by `delta` rows without leaving the list.
+/// Inputs: `selected`, the current index; `len`, the list length; `delta`, rows to move.
+/// Output: the new index, clamped to `0..len`.
+/// Side effects: none.
+fn step_selection(selected: usize, len: usize, delta: isize) -> usize {
+    clamped_selection(selected.saturating_add_signed(delta), len)
+}
+
+/// Purpose: the other focus target.
+/// Inputs: `focus`, the current focus.
+/// Output: `Input` for `List` and vice versa.
+/// Side effects: none.
+fn toggled_focus(focus: Focus) -> Focus {
+    match focus {
+        Focus::List => Focus::Input,
+        Focus::Input => Focus::List,
+    }
+}
+
+/// Purpose: the other list mode.
+/// Inputs: `mode`, the current mode.
+/// Output: `Contacts` for `Chats` and vice versa.
+/// Side effects: none.
+fn toggled_list_mode(mode: ListMode) -> ListMode {
+    match mode {
+        ListMode::Chats => ListMode::Contacts,
+        ListMode::Contacts => ListMode::Chats,
+    }
+}
+
+/// Purpose: deliver an event to the application from a background task.
+/// Inputs: `events`, the app event sender; `event`, the event to deliver.
+/// Output: none.
+/// Side effects: pushes into the channel; a closed channel means the app is shutting down.
+fn send_event(events: &UnboundedSender<AppEvent>, event: AppEvent) {
+    // The receiver is dropped only when the main loop has exited: the result is not needed anymore.
+    let _ = events.send(event);
+}
+
+/// Purpose: run an AI completion in the background.
+/// Inputs: `ai`, the client; `events`, the app event sender; `chat_jid`, the chat being
+/// answered; `turns`, the conversation sent to the model.
+/// Output: none.
+/// Side effects: spawns a tokio task that performs the HTTP request and sends `AiReply`.
+fn spawn_ai_request(
+    ai: AiClient,
+    events: UnboundedSender<AppEvent>,
+    chat_jid: String,
+    turns: Vec<ChatTurn>,
+) {
+    tokio::spawn(async move {
+        let result = ai.complete(&turns).await.map_err(|err| format!("{err:#}"));
+        send_event(&events, AppEvent::AiReply { chat_jid, result });
+    });
+}
+
+/// Purpose: download an image and run a vision completion in the background.
+/// Inputs: `ai`, the client; `wa`, the WhatsApp handle; `events`, the app event sender;
+/// `request`, the chat being answered with its conversation and the image reference.
+/// Output: none.
+/// Side effects: spawns ONE tokio task that downloads the image, performs the HTTP request(s) and
+/// sends `AiReply`. If the vision path fails, the text-only courtesy completion is tried. The
+/// image bytes are never logged or stored.
+fn spawn_vision_request(
+    ai: AiClient,
+    wa: WaHandle,
+    events: UnboundedSender<AppEvent>,
+    request: VisionRequest,
+) {
+    tokio::spawn(async move {
+        let result = complete_with_fallback(&ai, &wa, &request).await;
+        send_event(&events, AppEvent::AiReply { chat_jid: request.chat_jid, result });
+    });
+}
+
+/// Purpose: answer an image message, falling back to the courtesy reply if vision fails.
+/// Inputs: `ai`, the client; `wa`, the WhatsApp handle; `request`, the vision request with both
+/// the vision turns and the courtesy `fallback_turns`.
+/// Output: the vision answer, else the courtesy text; if both fail, an error text containing both
+/// errors (see `combine_errors`).
+/// Side effects: CDN download and up to two HTTPS requests; the bytes stay in memory only.
+async fn complete_with_fallback(
+    ai: &AiClient,
+    wa: &WaHandle,
+    request: &VisionRequest,
+) -> Result<String, String> {
+    let vision_error =
+        match complete_with_downloaded_image(ai, wa, &request.turns, &request.image).await {
+            Ok(text) => return Ok(text),
+            Err(err) => err,
+        };
+    ai.complete(&request.fallback_turns)
+        .await
+        .map_err(|err| combine_errors(&vision_error, &format!("{err:#}")))
+}
+
+/// Purpose: merge the vision error and the courtesy fallback error into one message.
+/// Inputs: `vision_error`, the error of the vision path; `fallback_error`, the error of the
+/// courtesy completion.
+/// Output: the vision error followed by the fallback error, tagged with
+/// `STATUS_FALLBACK_ERROR_PREFIX`.
+/// Side effects: none.
+fn combine_errors(vision_error: &str, fallback_error: &str) -> String {
+    format!("{vision_error}; {STATUS_FALLBACK_ERROR_PREFIX}: {fallback_error}")
+}
+
+/// Purpose: download an image and ask the vision model about it.
+/// Inputs: `ai`, the client; `wa`, the WhatsApp handle; `turns`, the conversation; `image`, the
+/// reference of the image to download.
+/// Output: the generated text, or an error text (download errors carry
+/// `STATUS_IMAGE_DOWNLOAD_PREFIX`).
+/// Side effects: CDN download and one HTTPS request; the bytes stay in memory only.
+async fn complete_with_downloaded_image(
+    ai: &AiClient,
+    wa: &WaHandle,
+    turns: &[ChatTurn],
+    image: &ImageRef,
+) -> Result<String, String> {
+    let downloaded = wa
+        .download_image(image)
+        .await
+        .map_err(|err| format!("{STATUS_IMAGE_DOWNLOAD_PREFIX}: {err:#}"))?;
+    let input = ImageInput { mime: downloaded.mime, bytes: downloaded.bytes };
+    ai.complete_with_image(turns, &input)
+        .await
+        .map_err(|err| format!("{err:#}"))
+}
+
+/// Purpose: decide whether the image of a message is sent to the model.
+/// Inputs: `has_image`, whether the message carries a downloadable image; `has_vision`, whether
+/// the AI client has a vision model configured.
+/// Output: `true` only when both hold; otherwise the text-only (courtesy) path is used.
+/// Side effects: none.
+fn vision_applies(has_image: bool, has_vision: bool) -> bool {
+    has_image && has_vision
+}
+
+/// Purpose: send a text message in the background.
+/// Inputs: `wa`, the WhatsApp handle; `events`, the app event sender; `outgoing`, the message.
+/// Output: none.
+/// Side effects: spawns a tokio task that sends the text and then emits `Sent`.
+fn spawn_send(wa: WaHandle, events: UnboundedSender<AppEvent>, outgoing: OutgoingMessage) {
+    tokio::spawn(async move {
+        let OutgoingMessage { chat_jid, text, is_ai } = outgoing;
+        let result = wa
+            .send_text(&chat_jid, &text)
+            .await
+            .map_err(|err| format!("{err:#}"));
+        send_event(&events, AppEvent::Sent { chat_jid, text, is_ai, result });
+    });
+}
+
+/// Purpose: build the AI client, available only when both API key and system prompt are set.
+/// Inputs: `config`, the loaded configuration.
+/// Output: the client (if any) and the initial status text (empty when the AI is available).
+/// Side effects: none. A missing system prompt yields `STATUS_AI_NOT_CONFIGURED`; a client
+/// build failure is reported through `STATUS_AI_UNAVAILABLE_PREFIX`.
+fn build_ai(config: &Config) -> (Option<AiClient>, String) {
+    if config.system_prompt.is_none() {
+        return (None, STATUS_AI_NOT_CONFIGURED.to_owned());
+    }
+    match AiClient::new(config) {
+        Ok(client) => (Some(client), String::new()),
+        Err(err) => (None, format!("{STATUS_AI_UNAVAILABLE_PREFIX}: {err:#}")),
+    }
+}
+
+/// Purpose: decide whether the autoreply may be enabled for a chat.
+/// Inputs: `jid`, the chat identifier; `ai_configured`, whether an AI client is available.
+/// Output: `Ok(())` when enabling is allowed, otherwise `Err` with the status text to show.
+/// Side effects: none. The AI check comes first, then the private-chat restriction.
+fn can_enable_autoreply(jid: &str, ai_configured: bool) -> Result<(), &'static str> {
+    if !ai_configured {
+        return Err(STATUS_AI_NOT_CONFIGURED);
+    }
+    if !is_private_chat(jid) {
+        return Err(STATUS_AUTOREPLY_PRIVATE_ONLY);
+    }
+    Ok(())
+}
+
+/// Purpose: tell whether the modifiers denote a Ctrl command chord rather than AltGr.
+/// Inputs: `modifiers`, the modifiers of a key event.
+/// Output: `true` when Control is held and Alt is not; `false` otherwise.
+/// Side effects: none. On Windows AltGr is reported as Ctrl+Alt, so that combination is
+/// treated as plain text input (e.g. `@`, `#`, `€` on the Italian layout).
+fn is_command(modifiers: KeyModifiers) -> bool {
+    modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn can_enable_autoreply_allows_private_chat_with_ai() {
+        assert_eq!(can_enable_autoreply("393331234567@s.whatsapp.net", true), Ok(()));
+    }
+
+    #[test]
+    fn can_enable_autoreply_allows_self_chat_lid_jid() {
+        assert_eq!(can_enable_autoreply("123456789@lid", true), Ok(()));
+    }
+
+    #[test]
+    fn can_enable_autoreply_refuses_non_private_chats() {
+        assert_eq!(can_enable_autoreply("120363000000@g.us", true), Err(STATUS_AUTOREPLY_PRIVATE_ONLY));
+    }
+
+    #[test]
+    fn can_enable_autoreply_refuses_without_ai() {
+        assert_eq!(
+            can_enable_autoreply("393331234567@s.whatsapp.net", false),
+            Err(STATUS_AI_NOT_CONFIGURED)
+        );
+    }
+
+    #[test]
+    fn vision_applies_requires_image_and_vision_model() {
+        assert!(vision_applies(true, true));
+        assert!(!vision_applies(true, false));
+        assert!(!vision_applies(false, true));
+        assert!(!vision_applies(false, false));
+    }
+
+    #[test]
+    fn combine_errors_keeps_both_messages_in_order() {
+        let combined = combine_errors("Download immagine fallito: timeout", "HTTP 500");
+        assert_eq!(
+            combined,
+            format!("Download immagine fallito: timeout; {STATUS_FALLBACK_ERROR_PREFIX}: HTTP 500")
+        );
+    }
+
+    #[test]
+    fn clamped_selection_stays_inside_list() {
+        assert_eq!(clamped_selection(5, 3), 2);
+        assert_eq!(clamped_selection(1, 3), 1);
+        assert_eq!(clamped_selection(4, 0), 0);
+    }
+
+    #[test]
+    fn step_selection_moves_and_clamps_at_edges() {
+        assert_eq!(step_selection(0, 3, -1), 0);
+        assert_eq!(step_selection(0, 3, 1), 1);
+        assert_eq!(step_selection(2, 3, 1), 2);
+        assert_eq!(step_selection(2, 3, -1), 1);
+        assert_eq!(step_selection(0, 0, 1), 0);
+    }
+
+    #[test]
+    fn toggles_are_involutions() {
+        assert_eq!(toggled_focus(Focus::List), Focus::Input);
+        assert_eq!(toggled_focus(toggled_focus(Focus::List)), Focus::List);
+        assert_eq!(toggled_list_mode(ListMode::Chats), ListMode::Contacts);
+        assert_eq!(toggled_list_mode(toggled_list_mode(ListMode::Chats)), ListMode::Chats);
+    }
+
+    #[test]
+    fn is_command_excludes_altgr_chords() {
+        assert!(is_command(KeyModifiers::CONTROL));
+        assert!(!is_command(KeyModifiers::CONTROL | KeyModifiers::ALT));
+        assert!(!is_command(KeyModifiers::NONE));
+        assert!(is_command(KeyModifiers::CONTROL | KeyModifiers::SHIFT));
+    }
+}

+ 679 - 0
src/autoreply.rs

@@ -0,0 +1,679 @@
+//! Pure decision rules for the per-chat AI autoreply and construction of the AI context.
+//!
+//! No I/O, no network, no persistence: callers feed messages and a clock value in,
+//! and get a decision or a list of chat turns out.
+
+use std::collections::HashMap;
+
+use crate::ai::{ChatTurn, Role};
+use crate::history::StoredMessage;
+
+/// Minimum number of seconds between two automatic replies in the same chat.
+pub const COOLDOWN_SECONDS: i64 = 30;
+
+/// Maximum number of history messages included in the AI context.
+pub const CONTEXT_MESSAGES: usize = 20;
+
+/// Instruction replacing a voice note that triggers the reply.
+const VOICE_NOTE_INSTRUCTION: &str = "[Il contatto ha inviato un messaggio vocale che non puoi ascoltare. Rispondi con una frase breve e cortese, formulata ogni volta in modo diverso dalle risposte precedenti, dicendo che ora non puoi ascoltare e chiedendo gentilmente di scrivere il messaggio.]";
+
+/// Text replacing an older voice note in the history.
+const VOICE_NOTE_HISTORY: &str = "[Il contatto ha inviato un messaggio vocale]";
+
+/// Instruction replacing an image that triggers the reply when it is attached to the request.
+const IMAGE_VISION_INSTRUCTION: &str =
+    "[Il contatto ha inviato l'immagine allegata. Rispondi in modo pertinente a ciò che vedi.]";
+
+/// Instruction replacing an image that triggers the reply when it cannot be viewed.
+const IMAGE_NO_VISION_INSTRUCTION: &str = "[Il contatto ha inviato un'immagine che ora non puoi vedere. Rispondi con una frase breve e cortese, formulata ogni volta in modo diverso dalle risposte precedenti, dicendo che al momento non riesci a visualizzarla e chiedendo gentilmente di descriverla per iscritto.]";
+
+/// Text replacing an older image in the history.
+const IMAGE_HISTORY: &str = "[Il contatto ha inviato un'immagine]";
+
+/// Prefix of the line carrying the image caption, appended after the image text.
+const IMAGE_CAPTION_PREFIX: &str = "\nDidascalia: ";
+
+/// JID server part identifying one-to-one chats by phone number.
+const SERVER_USER: &str = "s.whatsapp.net";
+
+/// JID server part identifying one-to-one chats by LID.
+const SERVER_LID: &str = "lid";
+
+/// Separator between the user part and the server part of a JID.
+const JID_SERVER_SEPARATOR: char = '@';
+
+/// Separator between the user part of a JID and its device number.
+const DEVICE_SEPARATOR: char = ':';
+
+/// Returns whether the JID identifies a one-to-one (private) chat.
+///
+/// Inputs: `jid`, a WhatsApp JID such as `39333...@s.whatsapp.net`.
+/// Output: `true` only when the server part is `s.whatsapp.net` or `lid`;
+/// `false` for groups (`g.us`), broadcasts (incl. `status@broadcast`),
+/// newsletters, malformed JIDs and any other server.
+/// Side effects: none.
+pub fn is_private_chat(jid: &str) -> bool {
+    jid.rsplit_once(JID_SERVER_SEPARATOR)
+        .is_some_and(|(_, server)| server == SERVER_USER || server == SERVER_LID)
+}
+
+/// Decides when an incoming message deserves an automatic AI reply.
+///
+/// Tracks the process start time (to ignore backlog) and the last reply
+/// instant per chat (to enforce the cooldown).
+#[derive(Debug, Clone)]
+pub struct ReplyGate {
+    /// Unix seconds at which the application started; older messages are backlog.
+    started_at: i64,
+    /// Unix seconds of the last triggered reply, keyed by chat JID.
+    last_reply_at: HashMap<String, i64>,
+    /// Own JIDs of the logged-in user, stored without device suffix.
+    own_jids: Vec<String>,
+}
+
+impl ReplyGate {
+    /// Creates a gate with no recorded replies and no known own JIDs.
+    ///
+    /// Inputs: `started_at`, unix seconds of application start.
+    /// Output: a new `ReplyGate`.
+    /// Side effects: none.
+    pub fn new(started_at: i64) -> ReplyGate {
+        ReplyGate {
+            started_at,
+            last_reply_at: HashMap::new(),
+            own_jids: Vec::new(),
+        }
+    }
+
+    /// Replaces the set of JIDs identifying the logged-in user.
+    ///
+    /// Inputs: `jids`, the user's own JIDs (phone and/or LID), with or
+    /// without device suffix.
+    /// Output: none.
+    /// Side effects: overwrites the previously known own JIDs.
+    pub fn set_own_jids(&mut self, jids: Vec<String>) {
+        self.own_jids = jids.iter().map(|jid| strip_device_suffix(jid)).collect();
+    }
+
+    /// Returns whether a chat is the user's self-chat ("Messaggi a te stesso").
+    ///
+    /// Inputs: `chat_jid`, the chat JID to check.
+    /// Output: `true` if it equals one of the own JIDs, ignoring a device
+    /// suffix (`:N`) in the user part; `false` when no own JIDs are known.
+    /// Side effects: none.
+    pub fn is_self_chat(&self, chat_jid: &str) -> bool {
+        let chat = strip_device_suffix(chat_jid);
+        self.own_jids.contains(&chat)
+    }
+
+    /// Decides whether the sender of `message` may trigger a reply.
+    ///
+    /// Inputs: the incoming `message`.
+    /// Output: `true` for messages not sent by the user; for messages sent by
+    /// the user, `true` only in the self-chat and when not generated by the AI.
+    /// Side effects: none.
+    fn sender_allowed(&self, message: &StoredMessage) -> bool {
+        !message.from_me || (self.is_self_chat(&message.chat_jid) && !message.is_ai)
+    }
+
+    /// Decides whether `message` must trigger an automatic reply.
+    ///
+    /// Inputs: the incoming `message`, the per-chat `autoreply_enabled` flag,
+    /// and `now` as unix seconds.
+    /// Output: `true` only if autoreply is enabled, the message is not from
+    /// the user (except human-written messages in the self-chat), the chat is
+    /// private, the message is not older than the start time, the text is
+    /// neither blank nor a media/unsupported placeholder (see
+    /// `crate::whatsapp::is_placeholder`; voice notes and images are not
+    /// placeholders and do trigger), and the chat cooldown has elapsed.
+    /// Side effects: none (call `record_reply` when a reply is triggered).
+    pub fn should_reply(&self, message: &StoredMessage, autoreply_enabled: bool, now: i64) -> bool {
+        autoreply_enabled
+            && self.sender_allowed(message)
+            && is_private_chat(&message.chat_jid)
+            && message.timestamp >= self.started_at
+            && !(message.text.trim().is_empty() || crate::whatsapp::is_placeholder(&message.text))
+            && self.cooldown_elapsed(&message.chat_jid, now)
+    }
+
+    /// Records that a reply has been triggered for a chat.
+    ///
+    /// Inputs: `chat_jid`, the chat being answered; `now`, unix seconds.
+    /// Output: none.
+    /// Side effects: updates the last-reply instant of the chat; call it
+    /// before the AI request to prevent double replies.
+    pub fn record_reply(&mut self, chat_jid: &str, now: i64) {
+        self.last_reply_at.insert(chat_jid.to_owned(), now);
+    }
+
+    /// Checks whether the cooldown for a chat has elapsed.
+    ///
+    /// Inputs: `chat_jid`, the chat to check; `now`, unix seconds.
+    /// Output: `true` if the chat never got a reply or at least
+    /// `COOLDOWN_SECONDS` have passed since the last one.
+    /// Side effects: none.
+    fn cooldown_elapsed(&self, chat_jid: &str, now: i64) -> bool {
+        self.last_reply_at
+            .get(chat_jid)
+            .is_none_or(|last| now.saturating_sub(*last) >= COOLDOWN_SECONDS)
+    }
+}
+
+/// Removes the device suffix (`:N`) from the user part of a JID.
+///
+/// Inputs: `jid`, e.g. `39333:12@s.whatsapp.net`.
+/// Output: the JID without device suffix (`39333@s.whatsapp.net`); a JID
+/// without server part is only stripped of anything after the first `:`.
+/// Side effects: none.
+fn strip_device_suffix(jid: &str) -> String {
+    let (user, server) = match jid.split_once(JID_SERVER_SEPARATOR) {
+        Some((user, server)) => (user, Some(server)),
+        None => (jid, None),
+    };
+    let user = user.split(DEVICE_SEPARATOR).next().unwrap_or(user);
+    match server {
+        Some(server) => format!("{user}{JID_SERVER_SEPARATOR}{server}"),
+        None => user.to_owned(),
+    }
+}
+
+/// Maps a history message to the role it has in the AI conversation.
+///
+/// Inputs: `message`, the history message; `self_chat`, whether the chat is
+/// the user's self-chat.
+/// Output: in a self-chat `Assistant` for AI messages and `User` otherwise;
+/// in other chats `Assistant` for messages sent by the user and `User` otherwise.
+/// Side effects: none.
+fn turn_role(message: &StoredMessage, self_chat: bool) -> Role {
+    let is_assistant = if self_chat { message.is_ai } else { message.from_me };
+    if is_assistant { Role::Assistant } else { Role::User }
+}
+
+/// Describes the chat being answered, as needed to build the AI context.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct TurnContext {
+    /// Whether the chat is the user's self-chat (`is_ai` decides the role).
+    pub self_chat: bool,
+    /// Whether the triggering image is actually sent to a vision-capable model.
+    pub vision: bool,
+}
+
+/// Builds the AI-facing text of an image message.
+///
+/// Inputs: `text`, the stored image text (`[immagine]` with optional caption);
+/// `is_last`, whether it is the triggering message; `vision`, whether the
+/// image is attached to the request.
+/// Output: the instruction matching the case (see the `IMAGE_*` consts),
+/// followed by `IMAGE_CAPTION_PREFIX` and the caption when one is present.
+/// Side effects: none.
+fn image_content(text: &str, is_last: bool, vision: bool) -> String {
+    let instruction = match (is_last, vision) {
+        (true, true) => IMAGE_VISION_INSTRUCTION,
+        (true, false) => IMAGE_NO_VISION_INSTRUCTION,
+        (false, _) => IMAGE_HISTORY,
+    };
+    match crate::whatsapp::image_caption(text).map(str::trim).filter(|caption| !caption.is_empty()) {
+        Some(caption) => format!("{instruction}{IMAGE_CAPTION_PREFIX}{caption}"),
+        None => instruction.to_owned(),
+    }
+}
+
+/// Builds the AI-facing text of a `User`-role message.
+///
+/// Inputs: `text`, the stored message text; `is_last`, whether it is the
+/// triggering message (final one of the context window); `vision`, whether
+/// the triggering image is attached to the request.
+/// Output: an instruction for voice notes and images (see the voice/image
+/// consts), the unchanged text for anything else.
+/// Side effects: none.
+fn user_content(text: &str, is_last: bool, vision: bool) -> String {
+    if crate::whatsapp::is_voice_note(text) {
+        let instruction = if is_last { VOICE_NOTE_INSTRUCTION } else { VOICE_NOTE_HISTORY };
+        return instruction.to_owned();
+    }
+    if crate::whatsapp::is_image(text) {
+        return image_content(text, is_last, vision);
+    }
+    text.to_owned()
+}
+
+/// Converts a history message into an AI turn.
+///
+/// Inputs: `message`, the history message; `is_last`, whether it is the
+/// triggering message; `context`, the chat description.
+/// Output: a turn with the mapped role; only `User` turns get voice/image
+/// content transformation, `Assistant` turns keep their text.
+/// Side effects: none.
+fn message_to_turn(message: &StoredMessage, is_last: bool, context: TurnContext) -> ChatTurn {
+    let role = turn_role(message, context.self_chat);
+    let content = match role {
+        Role::User => user_content(&message.text, is_last, context.vision),
+        _ => message.text.clone(),
+    };
+    ChatTurn { role, content }
+}
+
+/// Builds the ordered list of turns sent to the AI.
+///
+/// Inputs: `system_prompt`, the instructions for the assistant; `messages`,
+/// the chat history in ascending chronological order; `context`, whether the
+/// chat is the user's self-chat (where `from_me` is true for both the human
+/// and the AI, so `is_ai` decides the role) and whether the triggering image
+/// is sent to a vision-capable model.
+/// Output: a `System` turn first, then the last `CONTEXT_MESSAGES` messages
+/// mapped to `Assistant` or `User` (see `turn_role`), skipping messages with
+/// blank text. User voice notes and images become instructions (see
+/// `user_content`); the final message of the window is the triggering one.
+/// Side effects: none.
+pub fn build_turns(system_prompt: &str, messages: &[StoredMessage], context: TurnContext) -> Vec<ChatTurn> {
+    let window = &messages[messages.len().saturating_sub(CONTEXT_MESSAGES)..];
+    let system = ChatTurn {
+        role: Role::System,
+        content: system_prompt.to_owned(),
+    };
+    let history = window
+        .iter()
+        .enumerate()
+        .filter(|(_, message)| !message.text.trim().is_empty())
+        .map(|(index, message)| message_to_turn(message, index + 1 == window.len(), context));
+    std::iter::once(system).chain(history).collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const PRIVATE_JID: &str = "393331234567@s.whatsapp.net";
+    const OTHER_PRIVATE_JID: &str = "393339999999@s.whatsapp.net";
+    const LID_JID: &str = "123456789@lid";
+    const GROUP_JID: &str = "120363000000000000@g.us";
+    const STATUS_JID: &str = "status@broadcast";
+    const NEWSLETTER_JID: &str = "120363111111111111@newsletter";
+    const STARTED_AT: i64 = 1_000;
+    const NOW: i64 = 2_000;
+    const SYSTEM_PROMPT: &str = "be nice";
+    const SELF_CHAT: TurnContext = TurnContext { self_chat: true, vision: false };
+    const WITH_VISION: TurnContext = TurnContext { self_chat: false, vision: true };
+    const VOICE_TEXT: &str = "[messaggio vocale]";
+    const IMAGE_TEXT: &str = "[immagine]";
+    const CAPTION: &str = "il mio gatto";
+    const IMAGE_WITH_CAPTION: &str = "[immagine] il mio gatto";
+
+    /// Builds an incoming, private-chat message that passes every gate rule.
+    fn incoming() -> StoredMessage {
+        StoredMessage {
+            message_id: "m1".to_owned(),
+            chat_jid: PRIVATE_JID.to_owned(),
+            sender_jid: PRIVATE_JID.to_owned(),
+            sender_name: None,
+            text: "hello".to_owned(),
+            timestamp: NOW,
+            from_me: false,
+            is_ai: false,
+        }
+    }
+
+    /// Builds a history message with the given text and direction.
+    fn history_message(text: &str, from_me: bool) -> StoredMessage {
+        StoredMessage {
+            text: text.to_owned(),
+            from_me,
+            ..incoming()
+        }
+    }
+
+    #[test]
+    fn private_chat_accepts_user_and_lid_servers() {
+        assert!(is_private_chat(PRIVATE_JID));
+        assert!(is_private_chat(LID_JID));
+        assert!(is_private_chat("393331234567:12@s.whatsapp.net"));
+    }
+
+    #[test]
+    fn private_chat_rejects_other_servers() {
+        assert!(!is_private_chat(GROUP_JID));
+        assert!(!is_private_chat(STATUS_JID));
+        assert!(!is_private_chat(NEWSLETTER_JID));
+        assert!(!is_private_chat("123@other.example"));
+        assert!(!is_private_chat("no-separator"));
+        assert!(!is_private_chat(""));
+    }
+
+    #[test]
+    fn should_reply_happy_path() {
+        let gate = ReplyGate::new(STARTED_AT);
+        assert!(gate.should_reply(&incoming(), true, NOW));
+    }
+
+    #[test]
+    fn should_reply_false_when_disabled() {
+        let gate = ReplyGate::new(STARTED_AT);
+        assert!(!gate.should_reply(&incoming(), false, NOW));
+    }
+
+    #[test]
+    fn should_reply_false_when_from_me() {
+        let gate = ReplyGate::new(STARTED_AT);
+        let message = StoredMessage { from_me: true, ..incoming() };
+        assert!(!gate.should_reply(&message, true, NOW));
+    }
+
+    /// Builds a gate that knows `PRIVATE_JID` as the user's own JID.
+    fn gate_with_own_jid() -> ReplyGate {
+        let mut gate = ReplyGate::new(STARTED_AT);
+        gate.set_own_jids(vec![PRIVATE_JID.to_owned()]);
+        gate
+    }
+
+    #[test]
+    fn is_self_chat_matches_own_jid() {
+        assert!(gate_with_own_jid().is_self_chat(PRIVATE_JID));
+    }
+
+    #[test]
+    fn is_self_chat_ignores_device_suffix() {
+        let mut gate = ReplyGate::new(STARTED_AT);
+        gate.set_own_jids(vec!["393331234567:12@s.whatsapp.net".to_owned()]);
+        assert!(gate.is_self_chat(PRIVATE_JID));
+        assert!(gate_with_own_jid().is_self_chat("393331234567:7@s.whatsapp.net"));
+    }
+
+    #[test]
+    fn is_self_chat_false_without_own_jids() {
+        assert!(!ReplyGate::new(STARTED_AT).is_self_chat(PRIVATE_JID));
+    }
+
+    #[test]
+    fn is_self_chat_false_for_other_jid() {
+        let gate = gate_with_own_jid();
+        assert!(!gate.is_self_chat(OTHER_PRIVATE_JID));
+        assert!(!gate.is_self_chat(GROUP_JID));
+        assert!(!gate.is_self_chat("393331234567@lid"));
+    }
+
+    #[test]
+    fn is_self_chat_matches_any_of_several_own_jids() {
+        let mut gate = ReplyGate::new(STARTED_AT);
+        gate.set_own_jids(vec![PRIVATE_JID.to_owned(), LID_JID.to_owned()]);
+        assert!(gate.is_self_chat(LID_JID));
+        assert!(gate.is_self_chat(PRIVATE_JID));
+    }
+
+    #[test]
+    fn should_reply_true_for_human_from_me_in_self_chat() {
+        let message = StoredMessage { from_me: true, ..incoming() };
+        assert!(gate_with_own_jid().should_reply(&message, true, NOW));
+    }
+
+    #[test]
+    fn should_reply_false_for_ai_message_in_self_chat() {
+        let message = StoredMessage { from_me: true, is_ai: true, ..incoming() };
+        assert!(!gate_with_own_jid().should_reply(&message, true, NOW));
+    }
+
+    #[test]
+    fn should_reply_false_for_from_me_in_non_self_chat() {
+        let message = StoredMessage {
+            from_me: true,
+            chat_jid: OTHER_PRIVATE_JID.to_owned(),
+            ..incoming()
+        };
+        assert!(!gate_with_own_jid().should_reply(&message, true, NOW));
+    }
+
+    #[test]
+    fn should_reply_self_chat_still_honors_other_rules() {
+        let message = StoredMessage { from_me: true, ..incoming() };
+        let mut gate = gate_with_own_jid();
+        assert!(!gate.should_reply(&message, false, NOW));
+        gate.record_reply(PRIVATE_JID, NOW);
+        assert!(!gate.should_reply(&message, true, NOW));
+    }
+
+    #[test]
+    fn should_reply_false_for_non_private_chats() {
+        let gate = ReplyGate::new(STARTED_AT);
+        for jid in [GROUP_JID, STATUS_JID, NEWSLETTER_JID] {
+            let message = StoredMessage { chat_jid: jid.to_owned(), ..incoming() };
+            assert!(!gate.should_reply(&message, true, NOW), "jid {jid}");
+        }
+    }
+
+    #[test]
+    fn should_reply_false_for_backlog_timestamp() {
+        let gate = ReplyGate::new(STARTED_AT);
+        let message = StoredMessage { timestamp: STARTED_AT - 1, ..incoming() };
+        assert!(!gate.should_reply(&message, true, NOW));
+    }
+
+    #[test]
+    fn should_reply_true_at_exact_start_timestamp() {
+        let gate = ReplyGate::new(STARTED_AT);
+        let message = StoredMessage { timestamp: STARTED_AT, ..incoming() };
+        assert!(gate.should_reply(&message, true, NOW));
+    }
+
+    #[test]
+    fn should_reply_false_for_blank_text() {
+        let gate = ReplyGate::new(STARTED_AT);
+        for text in ["", "   ", "\n\t "] {
+            let message = StoredMessage { text: text.to_owned(), ..incoming() };
+            assert!(!gate.should_reply(&message, true, NOW), "text {text:?}");
+        }
+    }
+
+    #[test]
+    fn should_reply_false_for_placeholder_text() {
+        let gate = ReplyGate::new(STARTED_AT);
+        let message = StoredMessage { text: "[media]".to_owned(), ..incoming() };
+        assert!(!gate.should_reply(&message, true, NOW));
+    }
+
+    #[test]
+    fn should_reply_true_for_voice_note_and_image() {
+        let gate = ReplyGate::new(STARTED_AT);
+        for text in [VOICE_TEXT, IMAGE_TEXT, IMAGE_WITH_CAPTION] {
+            let message = StoredMessage { text: text.to_owned(), ..incoming() };
+            assert!(gate.should_reply(&message, true, NOW), "text {text:?}");
+        }
+    }
+
+    #[test]
+    fn should_reply_false_for_unsupported_placeholders() {
+        let gate = ReplyGate::new(STARTED_AT);
+        for text in ["[media]", "[messaggio non supportato]"] {
+            let message = StoredMessage { text: text.to_owned(), ..incoming() };
+            assert!(!gate.should_reply(&message, true, NOW), "text {text:?}");
+        }
+    }
+
+    #[test]
+    fn should_reply_false_during_cooldown() {
+        let mut gate = ReplyGate::new(STARTED_AT);
+        gate.record_reply(PRIVATE_JID, NOW);
+        assert!(!gate.should_reply(&incoming(), true, NOW));
+        assert!(!gate.should_reply(&incoming(), true, NOW + COOLDOWN_SECONDS - 1));
+    }
+
+    #[test]
+    fn should_reply_true_once_cooldown_elapsed() {
+        let mut gate = ReplyGate::new(STARTED_AT);
+        gate.record_reply(PRIVATE_JID, NOW);
+        assert!(gate.should_reply(&incoming(), true, NOW + COOLDOWN_SECONDS));
+    }
+
+    #[test]
+    fn cooldown_is_tracked_per_chat() {
+        let mut gate = ReplyGate::new(STARTED_AT);
+        gate.record_reply(OTHER_PRIVATE_JID, NOW);
+        assert!(gate.should_reply(&incoming(), true, NOW));
+    }
+
+    #[test]
+    fn build_turns_puts_system_first() {
+        let turns = build_turns(SYSTEM_PROMPT, &[history_message("hi", false)], TurnContext::default());
+        assert_eq!(turns.len(), 2);
+        assert_eq!(
+            turns[0],
+            ChatTurn { role: Role::System, content: SYSTEM_PROMPT.to_owned() }
+        );
+    }
+
+    #[test]
+    fn build_turns_with_no_messages_has_only_system() {
+        let turns = build_turns(SYSTEM_PROMPT, &[], TurnContext::default());
+        assert_eq!(turns.len(), 1);
+        assert_eq!(turns[0].role, Role::System);
+    }
+
+    #[test]
+    fn build_turns_maps_roles() {
+        let messages = [history_message("question", false), history_message("answer", true)];
+        let turns = build_turns(SYSTEM_PROMPT, &messages, TurnContext::default());
+        assert_eq!(turns[1], ChatTurn { role: Role::User, content: "question".to_owned() });
+        assert_eq!(turns[2], ChatTurn { role: Role::Assistant, content: "answer".to_owned() });
+    }
+
+    #[test]
+    fn build_turns_normal_chat_ignores_is_ai_flag() {
+        let ai_from_me = StoredMessage { is_ai: true, ..history_message("ai", true) };
+        let ai_received = StoredMessage { is_ai: true, ..history_message("odd", false) };
+        let turns = build_turns(SYSTEM_PROMPT, &[ai_from_me, ai_received], TurnContext::default());
+        assert_eq!(turns[1].role, Role::Assistant);
+        assert_eq!(turns[2].role, Role::User);
+    }
+
+    #[test]
+    fn build_turns_self_chat_maps_is_ai_to_assistant() {
+        let human = history_message("question", true);
+        let ai = StoredMessage { is_ai: true, ..history_message("answer", true) };
+        let turns = build_turns(SYSTEM_PROMPT, &[human, ai], SELF_CHAT);
+        assert_eq!(turns[1], ChatTurn { role: Role::User, content: "question".to_owned() });
+        assert_eq!(turns[2], ChatTurn { role: Role::Assistant, content: "answer".to_owned() });
+    }
+
+    #[test]
+    fn build_turns_self_chat_received_human_message_is_user() {
+        let turns = build_turns(SYSTEM_PROMPT, &[history_message("note", false)], SELF_CHAT);
+        assert_eq!(turns[1].role, Role::User);
+    }
+
+    #[test]
+    fn build_turns_keeps_only_last_context_messages() {
+        let extra = 5;
+        let messages: Vec<StoredMessage> = (0..CONTEXT_MESSAGES + extra)
+            .map(|i| history_message(&format!("msg {i}"), false))
+            .collect();
+        let turns = build_turns(SYSTEM_PROMPT, &messages, TurnContext::default());
+        assert_eq!(turns.len(), CONTEXT_MESSAGES + 1);
+        assert_eq!(turns[1].content, format!("msg {extra}"));
+        assert_eq!(
+            turns.last().map(|turn| turn.content.clone()),
+            Some(format!("msg {}", CONTEXT_MESSAGES + extra - 1))
+        );
+    }
+
+    #[test]
+    fn build_turns_skips_blank_texts() {
+        let messages = [
+            history_message("first", false),
+            history_message("   ", true),
+            history_message("", false),
+            history_message("last", true),
+        ];
+        let turns = build_turns(SYSTEM_PROMPT, &messages, TurnContext::default());
+        let contents: Vec<&str> = turns.iter().map(|turn| turn.content.as_str()).collect();
+        assert_eq!(contents, [SYSTEM_PROMPT, "first", "last"]);
+    }
+
+    /// Returns the content of the turn at `index` (system turn included).
+    fn content_at(turns: &[ChatTurn], index: usize) -> &str {
+        turns[index].content.as_str()
+    }
+
+    #[test]
+    fn build_turns_last_voice_note_becomes_instruction() {
+        let messages = [history_message("hi", false), history_message(VOICE_TEXT, false)];
+        let turns = build_turns(SYSTEM_PROMPT, &messages, TurnContext::default());
+        assert_eq!(content_at(&turns, 1), "hi");
+        assert_eq!(content_at(&turns, 2), VOICE_NOTE_INSTRUCTION);
+        assert_eq!(turns[2].role, Role::User);
+    }
+
+    #[test]
+    fn build_turns_older_voice_note_becomes_history_text() {
+        let messages = [history_message(VOICE_TEXT, false), history_message("ok", false)];
+        let turns = build_turns(SYSTEM_PROMPT, &messages, TurnContext::default());
+        assert_eq!(content_at(&turns, 1), VOICE_NOTE_HISTORY);
+        assert_eq!(content_at(&turns, 2), "ok");
+    }
+
+    #[test]
+    fn build_turns_last_image_with_vision_uses_vision_instruction() {
+        let turns = build_turns(SYSTEM_PROMPT, &[history_message(IMAGE_TEXT, false)], WITH_VISION);
+        assert_eq!(content_at(&turns, 1), IMAGE_VISION_INSTRUCTION);
+    }
+
+    #[test]
+    fn build_turns_last_image_with_vision_appends_caption() {
+        let turns = build_turns(SYSTEM_PROMPT, &[history_message(IMAGE_WITH_CAPTION, false)], WITH_VISION);
+        let expected = format!("{IMAGE_VISION_INSTRUCTION}\nDidascalia: {CAPTION}");
+        assert_eq!(content_at(&turns, 1), expected);
+    }
+
+    #[test]
+    fn build_turns_last_image_without_vision_asks_for_description() {
+        let turns = build_turns(SYSTEM_PROMPT, &[history_message(IMAGE_TEXT, false)], TurnContext::default());
+        assert_eq!(content_at(&turns, 1), IMAGE_NO_VISION_INSTRUCTION);
+        let captioned = [history_message(IMAGE_WITH_CAPTION, false)];
+        let turns = build_turns(SYSTEM_PROMPT, &captioned, TurnContext::default());
+        let expected = format!("{IMAGE_NO_VISION_INSTRUCTION}\nDidascalia: {CAPTION}");
+        assert_eq!(content_at(&turns, 1), expected);
+    }
+
+    #[test]
+    fn build_turns_older_image_becomes_history_text_regardless_of_vision() {
+        let messages = [
+            history_message(IMAGE_TEXT, false),
+            history_message(IMAGE_WITH_CAPTION, false),
+            history_message("ok", false),
+        ];
+        for context in [TurnContext::default(), WITH_VISION] {
+            let turns = build_turns(SYSTEM_PROMPT, &messages, context);
+            assert_eq!(content_at(&turns, 1), IMAGE_HISTORY);
+            assert_eq!(content_at(&turns, 2), format!("{IMAGE_HISTORY}\nDidascalia: {CAPTION}"));
+        }
+    }
+
+    #[test]
+    fn build_turns_leaves_assistant_turns_untouched() {
+        let messages = [
+            history_message(VOICE_TEXT, true),
+            history_message(IMAGE_WITH_CAPTION, true),
+            history_message("hi", false),
+        ];
+        let turns = build_turns(SYSTEM_PROMPT, &messages, WITH_VISION);
+        assert_eq!(turns[1], ChatTurn { role: Role::Assistant, content: VOICE_TEXT.to_owned() });
+        assert_eq!(turns[2], ChatTurn { role: Role::Assistant, content: IMAGE_WITH_CAPTION.to_owned() });
+    }
+
+    #[test]
+    fn build_turns_last_is_computed_after_truncation() {
+        let mut messages: Vec<StoredMessage> =
+            (0..CONTEXT_MESSAGES).map(|_| history_message(VOICE_TEXT, false)).collect();
+        messages.push(history_message(VOICE_TEXT, false));
+        let turns = build_turns(SYSTEM_PROMPT, &messages, TurnContext::default());
+        assert_eq!(turns.len(), CONTEXT_MESSAGES + 1);
+        assert_eq!(content_at(&turns, 1), VOICE_NOTE_HISTORY);
+        assert_eq!(content_at(&turns, CONTEXT_MESSAGES), VOICE_NOTE_INSTRUCTION);
+    }
+
+    #[test]
+    fn build_turns_self_chat_transforms_human_voice_note_not_ai_reply() {
+        let human = history_message(VOICE_TEXT, true);
+        let ai = StoredMessage { is_ai: true, ..history_message(VOICE_TEXT, true) };
+        let turns = build_turns(SYSTEM_PROMPT, &[ai, human], SELF_CHAT);
+        assert_eq!(turns[1], ChatTurn { role: Role::Assistant, content: VOICE_TEXT.to_owned() });
+        assert_eq!(turns[2], ChatTurn { role: Role::User, content: VOICE_NOTE_INSTRUCTION.to_owned() });
+    }
+}

+ 454 - 0
src/config.rs

@@ -0,0 +1,454 @@
+//! Configuration loader for `wh`.
+//!
+//! All settings come from the TOML file `config.toml` inside the data directory
+//! (`<platform config dir>/wh`, i.e. `%APPDATA%\wh` on Windows), falling back to built-in
+//! defaults for missing or blank keys (except `system_prompt`, which has no built-in
+//! default). No environment variable is ever read.
+//! Supported file keys: `openai_api_key`, `openai_base_url`, `openai_model`,
+//! `openai_vision_model`, `system_prompt`. Unknown keys are ignored. A missing file means
+//! all defaults; an unreadable or invalid file is an error. The data directory is fixed and
+//! cannot be changed from the file.
+//!
+//! The prompt defining the responder's identity lives only in `config.toml` (`system_prompt`);
+//! the binary embeds no prompt text. Without it the AI is not configured.
+
+use std::fmt;
+use std::io::ErrorKind;
+use std::path::{Path, PathBuf};
+
+use anyhow::{Context, Result, anyhow, bail};
+use serde::Deserialize;
+
+/// Default base URL of the OpenAI-compatible endpoint.
+const DEFAULT_BASE_URL: &str = "https://ollama.com/v1";
+/// Default model used for autoreplies.
+pub const DEFAULT_MODEL: &str = "deepseek-v4.1-flash";
+
+/// Name of the application sub-directory inside the platform config directory.
+const APP_DIR_NAME: &str = "wh";
+/// File name of the WhatsApp session database.
+const SESSION_DB_FILE: &str = "session.db";
+/// File name of the chat history database.
+const HISTORY_DB_FILE: &str = "history.db";
+/// File name of the optional TOML configuration file inside the data directory.
+const CONFIG_FILE: &str = "config.toml";
+/// Name of the file key holding the base URL, used in validation messages.
+const KEY_BASE_URL: &str = "openai_base_url";
+/// Byte order mark that Windows editors may prepend to UTF-8 files.
+const UTF8_BOM: char = '\u{feff}';
+
+/// Text shown in `Debug` output in place of a set API key.
+const DEBUG_KEY_MASK: &str = "***";
+/// Text shown in `Debug` output when no API key is set.
+const DEBUG_KEY_UNSET: &str = "<unset>";
+
+/// Validated application configuration, built from `config.toml` and built-in defaults.
+#[derive(Clone)]
+pub struct Config {
+    /// API key from `openai_api_key` (file), trimmed; `None` if unset or blank.
+    pub openai_api_key: Option<String>,
+    /// Endpoint base URL from `openai_base_url` (file), without trailing '/'.
+    pub openai_base_url: String,
+    /// Model name from `openai_model` (file).
+    pub openai_model: String,
+    /// Vision model name from `openai_vision_model` (file), trimmed; `None` if unset or
+    /// blank, which disables image understanding.
+    pub openai_vision_model: Option<String>,
+    /// System prompt from `system_prompt` (file), as written (not trimmed); `None` if unset
+    /// or blank, meaning the AI is not configured. There is no built-in default.
+    pub system_prompt: Option<String>,
+    /// Directory holding `config.toml` and the databases: `<platform config dir>/wh`.
+    pub data_dir: PathBuf,
+}
+
+/// Optional settings read from `config.toml`; unknown keys are ignored.
+/// Deliberately has no `Debug` impl so the API key can never be formatted by accident.
+#[derive(Default, Deserialize)]
+struct FileConfig {
+    /// API key for the OpenAI-compatible endpoint.
+    openai_api_key: Option<String>,
+    /// Base URL of the OpenAI-compatible endpoint.
+    openai_base_url: Option<String>,
+    /// Model name used for autoreplies.
+    openai_model: Option<String>,
+    /// Vision model name (unset or blank disables image understanding).
+    openai_vision_model: Option<String>,
+    /// System prompt for autoreplies.
+    system_prompt: Option<String>,
+}
+
+impl fmt::Debug for Config {
+    /// Purpose: format the config for debugging without leaking the API key.
+    /// Inputs: `f`, the target formatter.
+    /// Output: `fmt::Result` of the formatting operation.
+    /// Side effects: none (writes only to `f`).
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        let key = if self.openai_api_key.is_some() {
+            DEBUG_KEY_MASK
+        } else {
+            DEBUG_KEY_UNSET
+        };
+        f.debug_struct("Config")
+            .field("openai_api_key", &key)
+            .field("openai_base_url", &self.openai_base_url)
+            .field("openai_model", &self.openai_model)
+            .field("openai_vision_model", &self.openai_vision_model)
+            .field("system_prompt", &self.system_prompt)
+            .field("data_dir", &self.data_dir)
+            .finish()
+    }
+}
+
+/// Purpose: resolve the fixed data directory.
+/// Inputs: none.
+/// Output: the platform config dir joined with `wh`; error if the platform has no config dir.
+/// Side effects: queries the platform for the user config directory.
+fn default_data_dir() -> Result<PathBuf> {
+    dirs::config_dir()
+        .map(|d| d.join(APP_DIR_NAME))
+        .context("cannot determine the platform config directory")
+}
+
+/// Purpose: convert a byte offset into a 1-based line and column.
+/// Inputs: `content`, the text; `offset`, a byte offset into it.
+/// Output: `(line, column)`; the column counts bytes since the last newline.
+/// Side effects: none.
+fn line_col(content: &str, offset: usize) -> (usize, usize) {
+    let before = content.as_bytes().get(..offset).unwrap_or(content.as_bytes());
+    let line = before.iter().filter(|&&b| b == b'\n').count() + 1;
+    let line_start = before.iter().rposition(|&b| b == b'\n').map_or(0, |i| i + 1);
+    (line, before.len() - line_start + 1)
+}
+
+/// Purpose: parse the content of a `config.toml` file.
+/// Inputs: `content`, the file text (a leading UTF-8 BOM is tolerated).
+/// Output: the parsed `FileConfig`, or an error giving only line/column, never file content.
+/// Side effects: none.
+fn parse_config_file(content: &str) -> Result<FileConfig> {
+    let content = content.trim_start_matches(UTF8_BOM);
+    toml::from_str(content).map_err(|e| match e.span() {
+        Some(span) => {
+            let (line, col) = line_col(content, span.start);
+            anyhow!("invalid TOML syntax or value type at line {line}, column {col}")
+        }
+        None => anyhow!("invalid TOML syntax or value type"),
+    })
+}
+
+/// Purpose: load the configuration file at `path`.
+/// Inputs: `path`, the location of the file.
+/// Output: parsed settings; defaults if the file does not exist; error naming the path if unreadable or invalid.
+/// Side effects: reads the file from disk.
+fn read_config_file(path: &Path) -> Result<FileConfig> {
+    let content = match std::fs::read_to_string(path) {
+        Ok(content) => content,
+        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(FileConfig::default()),
+        Err(e) => {
+            return Err(e).with_context(|| format!("cannot read config file {}", path.display()));
+        }
+    };
+    parse_config_file(&content)
+        .with_context(|| format!("invalid config file {}", path.display()))
+}
+
+/// Purpose: discard blank (empty or whitespace-only) values.
+/// Inputs: `value`, an optional raw file value.
+/// Output: `value` unchanged if it contains non-whitespace characters, else `None`.
+/// Side effects: none.
+fn non_blank(value: Option<String>) -> Option<String> {
+    value.filter(|v| !v.trim().is_empty())
+}
+
+/// Purpose: discard blank values and trim the remaining ones.
+/// Inputs: `value`, an optional raw file value.
+/// Output: the trimmed value, or `None` if absent or blank.
+/// Side effects: none.
+fn trimmed_non_blank(value: Option<String>) -> Option<String> {
+    non_blank(value).map(|v| v.trim().to_owned())
+}
+
+/// Purpose: validate and normalise the endpoint base URL.
+/// Inputs: `raw`, the optional `openai_base_url` file value.
+/// Output: the trimmed URL without trailing '/' (default if absent/blank); error if only slashes remain.
+/// Side effects: none.
+fn resolve_base_url(raw: Option<String>) -> Result<String> {
+    let raw = non_blank(raw).unwrap_or_else(|| DEFAULT_BASE_URL.to_owned());
+    let url = raw.trim().trim_end_matches('/').to_owned();
+    if url.is_empty() {
+        bail!("{KEY_BASE_URL} in {CONFIG_FILE} must not be empty or only slashes");
+    }
+    Ok(url)
+}
+
+impl Config {
+    /// Purpose: load the configuration from `<data_dir>/config.toml` and built-in defaults.
+    /// Inputs: none (the data dir is `<platform config dir>/wh`).
+    /// Output: validated `Config`, or an error if the config dir is unavailable, the file is unreadable/invalid, or validation fails.
+    /// Side effects: reads the config file from disk; never reads environment variables.
+    pub fn load() -> Result<Config> {
+        let data_dir = default_data_dir()?;
+        let file = read_config_file(&data_dir.join(CONFIG_FILE))?;
+        Self::from_file(file, data_dir)
+    }
+
+    /// Purpose: build a validated configuration from parsed file settings.
+    /// Inputs: `file`, the parsed settings; `data_dir`, the directory to record in the config.
+    /// Output: validated `Config` with defaults for missing/blank keys, or an error for an invalid base URL.
+    /// Side effects: none.
+    fn from_file(file: FileConfig, data_dir: PathBuf) -> Result<Config> {
+        Ok(Config {
+            openai_api_key: trimmed_non_blank(file.openai_api_key),
+            openai_base_url: resolve_base_url(file.openai_base_url)?,
+            openai_model: trimmed_non_blank(file.openai_model)
+                .unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
+            openai_vision_model: trimmed_non_blank(file.openai_vision_model),
+            system_prompt: non_blank(file.system_prompt),
+            data_dir,
+        })
+    }
+
+    /// Purpose: make sure the data directory exists.
+    /// Inputs: none (uses `self.data_dir`).
+    /// Output: `Ok(())` if the directory exists after the call, otherwise an error.
+    /// Side effects: creates `data_dir` and any missing parents on disk.
+    pub fn ensure_data_dir(&self) -> Result<()> {
+        std::fs::create_dir_all(&self.data_dir)
+            .with_context(|| format!("cannot create data directory {}", self.data_dir.display()))
+    }
+
+    /// Purpose: locate the WhatsApp session database.
+    /// Inputs: none.
+    /// Output: `data_dir/session.db`.
+    /// Side effects: none.
+    pub fn session_db_path(&self) -> PathBuf {
+        self.data_dir.join(SESSION_DB_FILE)
+    }
+
+    /// Purpose: locate the chat history database.
+    /// Inputs: none.
+    /// Output: `data_dir/history.db`.
+    /// Side effects: none.
+    pub fn history_db_path(&self) -> PathBuf {
+        self.data_dir.join(HISTORY_DB_FILE)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Fake data directory recorded in test configs (never touched on disk).
+    const TEST_DATA_DIR: &str = "wh-test-data";
+    /// Directory (relative to the package root) where file tests create scratch files.
+    const TEST_SCRATCH_DIR: &str = "target";
+
+    /// Purpose: build a `Config` from TOML text through the production parse path.
+    /// Inputs: `toml_text`, the content of a hypothetical `config.toml`.
+    /// Output: the resulting `Config`, or the parse/validation error.
+    /// Side effects: none.
+    fn config_from(toml_text: &str) -> Result<Config> {
+        Config::from_file(parse_config_file(toml_text)?, PathBuf::from(TEST_DATA_DIR))
+    }
+
+    /// Purpose: build a `Config` from TOML text, failing the test on error.
+    /// Inputs: `toml_text`, the content of a hypothetical `config.toml`.
+    /// Output: the resulting `Config`.
+    /// Side effects: none.
+    fn ok_config(toml_text: &str) -> Config {
+        config_from(toml_text).expect("config must load")
+    }
+
+    /// Purpose: build a unique scratch file path for a file-based test.
+    /// Inputs: `tag`, a short test-specific suffix.
+    /// Output: path under the scratch dir, unique per process and tag.
+    /// Side effects: creates the scratch directory if missing.
+    fn scratch_path(tag: &str) -> PathBuf {
+        let dir = PathBuf::from(TEST_SCRATCH_DIR);
+        std::fs::create_dir_all(&dir).expect("scratch dir must be creatable");
+        dir.join(format!("wh-config-test-{}-{tag}.toml", std::process::id()))
+    }
+
+    #[test]
+    fn defaults_on_empty_file() {
+        let cfg = ok_config("");
+        assert_eq!(cfg.openai_api_key, None);
+        assert_eq!(cfg.openai_base_url, DEFAULT_BASE_URL);
+        assert_eq!(cfg.openai_model, DEFAULT_MODEL);
+        assert_eq!(cfg.openai_vision_model, None);
+        assert_eq!(cfg.system_prompt, None);
+        assert_eq!(cfg.data_dir, PathBuf::from(TEST_DATA_DIR));
+    }
+
+    #[test]
+    fn full_file_values_are_used() {
+        let cfg = ok_config(
+            "openai_api_key = \"sk-file\"\nopenai_base_url = \"http://file/v1\"\n\
+             openai_model = \"file-model\"\nopenai_vision_model = \"file-vision\"\n\
+             system_prompt = \"file prompt\"\n",
+        );
+        assert_eq!(cfg.openai_api_key.as_deref(), Some("sk-file"));
+        assert_eq!(cfg.openai_base_url, "http://file/v1");
+        assert_eq!(cfg.openai_model, "file-model");
+        assert_eq!(cfg.openai_vision_model.as_deref(), Some("file-vision"));
+        assert_eq!(cfg.system_prompt.as_deref(), Some("file prompt"));
+    }
+
+    #[test]
+    fn blank_values_fall_back_to_defaults_or_none() {
+        let cfg = ok_config(
+            "openai_api_key = \"   \"\nopenai_base_url = \"  \"\nopenai_model = \"\"\n\
+             openai_vision_model = \" \\t \"\nsystem_prompt = \"  \"\n",
+        );
+        assert_eq!(cfg.openai_api_key, None);
+        assert_eq!(cfg.openai_base_url, DEFAULT_BASE_URL);
+        assert_eq!(cfg.openai_model, DEFAULT_MODEL);
+        assert_eq!(cfg.openai_vision_model, None);
+        assert_eq!(cfg.system_prompt, None);
+    }
+
+    #[test]
+    fn multiline_system_prompt_is_preserved() {
+        const PROMPT: &str = "Line one.\n  Line two, indented.\nLine three.\n";
+        let cfg = ok_config(&format!("system_prompt = \"\"\"\n{PROMPT}\"\"\"\n"));
+        assert_eq!(cfg.system_prompt.as_deref(), Some(PROMPT));
+    }
+
+    #[test]
+    fn values_are_trimmed() {
+        let cfg = ok_config(
+            "openai_api_key = \"  sk-x \"\nopenai_model = \" m \"\n\
+             openai_vision_model = \"  vision-model \\t\"\n",
+        );
+        assert_eq!(cfg.openai_api_key.as_deref(), Some("sk-x"));
+        assert_eq!(cfg.openai_model, "m");
+        assert_eq!(cfg.openai_vision_model.as_deref(), Some("vision-model"));
+    }
+
+    #[test]
+    fn trailing_slash_is_trimmed() {
+        let cfg = ok_config("openai_base_url = \" http://localhost:8080/v1// \"\n");
+        assert_eq!(cfg.openai_base_url, "http://localhost:8080/v1");
+    }
+
+    #[test]
+    fn only_slashes_base_url_is_error_naming_key() {
+        let Err(err) = config_from("openai_base_url = \"///\"\n") else {
+            panic!("only-slashes base URL must fail");
+        };
+        let msg = format!("{err:#}");
+        assert!(msg.contains(KEY_BASE_URL), "unexpected message: {msg}");
+        assert!(msg.contains(CONFIG_FILE), "unexpected message: {msg}");
+    }
+
+    #[test]
+    fn debug_masks_api_key() {
+        const SECRET: &str = "sk-super-secret-value";
+        let cfg = ok_config(&format!("openai_api_key = \"{SECRET}\"\n"));
+        assert_eq!(cfg.openai_api_key.as_deref(), Some(SECRET));
+        let out = format!("{cfg:?}");
+        assert!(!out.contains(SECRET));
+        assert!(out.contains(DEBUG_KEY_MASK));
+        assert!(format!("{:?}", ok_config("")).contains(DEBUG_KEY_UNSET));
+    }
+
+    #[test]
+    fn vision_model_set_and_unset() {
+        let set = ok_config("openai_vision_model = \" v \"\n");
+        assert_eq!(set.openai_vision_model.as_deref(), Some("v"));
+        let unset = ok_config("openai_model = \"m\"\n");
+        assert_eq!(unset.openai_vision_model, None);
+        assert!(format!("{unset:?}").contains("openai_vision_model"));
+    }
+
+    #[test]
+    fn db_paths_are_under_data_dir() {
+        let cfg = ok_config("");
+        assert_eq!(cfg.session_db_path(), PathBuf::from(TEST_DATA_DIR).join(SESSION_DB_FILE));
+        assert_eq!(cfg.history_db_path(), PathBuf::from(TEST_DATA_DIR).join(HISTORY_DB_FILE));
+    }
+
+    #[test]
+    fn unknown_keys_are_ignored() {
+        let cfg = ok_config("future_option = 42\nwh_data_dir = \"/ignored\"\nopenai_model = \"m\"\n[extra]\nx = 1\n");
+        assert_eq!(cfg.openai_model, "m");
+        assert_eq!(cfg.data_dir, PathBuf::from(TEST_DATA_DIR));
+    }
+
+    #[test]
+    fn bom_and_crlf_are_tolerated() {
+        let cfg = ok_config("\u{feff}openai_model = \"m\"\r\nsystem_prompt = \"p\"\r\n");
+        assert_eq!(cfg.openai_model, "m");
+        assert_eq!(cfg.system_prompt.as_deref(), Some("p"));
+    }
+
+    #[test]
+    fn invalid_toml_is_error_without_leaking_content() {
+        const SECRET: &str = "sk-secret-in-bad-file";
+        let bad = format!("openai_model = \"ok\"\nopenai_api_key = \"{SECRET}\nnext = 1\n");
+        let Err(err) = parse_config_file(&bad) else {
+            panic!("invalid TOML must fail");
+        };
+        let msg = format!("{err:#}");
+        assert!(msg.contains("line 2"), "unexpected message: {msg}");
+        assert!(!msg.contains(SECRET));
+    }
+
+    #[test]
+    fn wrong_value_type_error_does_not_echo_value() {
+        const SECRET: &str = "987654321";
+        let Err(err) = parse_config_file(&format!("openai_api_key = {SECRET}\n")) else {
+            panic!("wrong type must fail");
+        };
+        assert!(!format!("{err:#}").contains(SECRET));
+    }
+
+    #[test]
+    fn missing_file_yields_defaults() {
+        let path = scratch_path("missing");
+        let file = read_config_file(&path).expect("missing file is not an error");
+        let cfg = Config::from_file(file, PathBuf::from(TEST_DATA_DIR)).expect("defaults must load");
+        assert_eq!(cfg.openai_model, DEFAULT_MODEL);
+        assert_eq!(cfg.openai_api_key, None);
+    }
+
+    #[test]
+    fn existing_file_is_read_from_disk() {
+        let path = scratch_path("existing");
+        std::fs::write(&path, "openai_model = \"disk-model\"\n").expect("temp file must be writable");
+        let result = read_config_file(&path);
+        std::fs::remove_file(&path).expect("temp file must be removable");
+        let cfg = Config::from_file(result.expect("file must parse"), PathBuf::from(TEST_DATA_DIR))
+            .expect("config must load");
+        assert_eq!(cfg.openai_model, "disk-model");
+    }
+
+    #[test]
+    fn invalid_file_error_names_path_without_values() {
+        const SECRET: &str = "sk-secret-on-disk";
+        let path = scratch_path("invalid");
+        std::fs::write(&path, format!("openai_api_key = \"{SECRET}\nx = 1\n"))
+            .expect("temp file must be writable");
+        let result = read_config_file(&path);
+        std::fs::remove_file(&path).expect("temp file must be removable");
+        let Err(err) = result else {
+            panic!("invalid TOML must fail");
+        };
+        let msg = format!("{err:#}");
+        assert!(msg.contains(&path.display().to_string()));
+        assert!(msg.contains("line 1"), "unexpected message: {msg}");
+        assert!(!msg.contains(SECRET));
+    }
+
+    #[test]
+    fn unreadable_file_error_names_path() {
+        const INVALID_UTF8: [u8; 3] = [0xff, 0xfe, 0xfd];
+        let path = scratch_path("utf8");
+        std::fs::write(&path, INVALID_UTF8).expect("temp file must be writable");
+        let result = read_config_file(&path);
+        std::fs::remove_file(&path).expect("temp file must be removable");
+        let Err(err) = result else {
+            panic!("non-UTF-8 content must be an error");
+        };
+        assert!(format!("{err:#}").contains(&path.display().to_string()));
+    }
+}

+ 593 - 0
src/history.rs

@@ -0,0 +1,593 @@
+//! SQLite-backed store for message history, chats (with autoreply flag) and contacts.
+
+use std::path::Path;
+
+use anyhow::{Context, Result};
+use rusqlite::{Connection, OptionalExtension, Row, params};
+
+const TABLE_MESSAGES: &str = "messages";
+const TABLE_CHATS: &str = "chats";
+const TABLE_CONTACTS: &str = "contacts";
+const INDEX_MESSAGES_BY_CHAT: &str = "idx_messages_chat_timestamp";
+
+/// Default `chats.last_timestamp` for chats that have no message yet.
+const DEFAULT_LAST_TIMESTAMP: i64 = 0;
+/// Default autoreply state of a chat that was never toggled.
+const DEFAULT_AUTOREPLY: bool = false;
+
+/// Separator between the user part and the server part of a WhatsApp jid.
+const JID_SEPARATOR: char = '@';
+
+/// A message persisted in the history.
+#[derive(Debug, Clone, PartialEq)]
+pub struct StoredMessage {
+    pub message_id: String,
+    pub chat_jid: String,
+    pub sender_jid: String,
+    pub sender_name: Option<String>,
+    pub text: String,
+    pub timestamp: i64,
+    pub from_me: bool,
+    pub is_ai: bool,
+}
+
+/// A chat as shown in the chat list.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ChatSummary {
+    pub jid: String,
+    pub name: String,
+    pub last_timestamp: i64,
+    pub autoreply: bool,
+}
+
+/// A named contact.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Contact {
+    pub jid: String,
+    pub name: String,
+}
+
+/// Local SQLite message history.
+pub struct History {
+    connection: Connection,
+}
+
+impl History {
+    /// Purpose: open (or create) the history database file and ensure the schema exists.
+    /// Inputs: `path` of the SQLite file; missing parent directories are created.
+    /// Output: a ready-to-use `History`.
+    /// Side effects: may create directories and the database file; creates missing tables.
+    pub fn open(path: &Path) -> Result<History> {
+        if let Some(parent) = path.parent().filter(|parent| !parent.as_os_str().is_empty()) {
+            std::fs::create_dir_all(parent)
+                .with_context(|| format!("create history directory {}", parent.display()))?;
+        }
+        let connection = Connection::open(path)
+            .with_context(|| format!("open history database {}", path.display()))?;
+
+        Self::from_connection(connection)
+    }
+
+    /// Purpose: create a throw-away history living only in memory (tests only).
+    /// Inputs: none.
+    /// Output: a ready-to-use `History`.
+    /// Side effects: creates the schema in the in-memory database.
+    #[cfg(test)]
+    pub fn open_in_memory() -> Result<History> {
+        let connection =
+            Connection::open_in_memory().context("open in-memory history database")?;
+
+        Self::from_connection(connection)
+    }
+
+    /// Purpose: wrap a connection and create the schema on it.
+    /// Inputs: an open SQLite connection.
+    /// Output: the `History` owning that connection.
+    /// Side effects: executes the `CREATE TABLE IF NOT EXISTS` statements.
+    fn from_connection(connection: Connection) -> Result<History> {
+        connection
+            .execute_batch(&schema_sql())
+            .context("create history schema")?;
+
+        Ok(History { connection })
+    }
+
+    /// Purpose: persist a message once, keeping the chat row up to date.
+    /// Inputs: the message to store (deduplicated by `message_id`).
+    /// Output: `true` if the message was newly inserted, `false` if it already existed.
+    /// Side effects: inserts into messages; upserts chats (last_timestamp = max, name from
+    /// the sender push name when the chat has none and the message is not from me), atomically.
+    pub fn save_message(&self, message: &StoredMessage) -> Result<bool> {
+        let context = || format!("save message {} in chat {}", message.message_id, message.chat_jid);
+        let transaction = self.connection.unchecked_transaction().with_context(context)?;
+
+        let inserted = insert_message(&transaction, message).with_context(context)?;
+        if inserted {
+            upsert_chat_for_message(&transaction, message).with_context(context)?;
+        }
+        transaction.commit().with_context(context)?;
+
+        Ok(inserted)
+    }
+
+    /// Purpose: fetch the latest messages of a chat.
+    /// Inputs: `chat_jid` and the maximum number of messages (`limit`).
+    /// Output: up to `limit` most recent messages, in ascending timestamp order.
+    /// Side effects: none (read only).
+    pub fn recent_messages(&self, chat_jid: &str, limit: usize) -> Result<Vec<StoredMessage>> {
+        let context = || format!("read last {limit} messages of chat {chat_jid}");
+        let sql = format!(
+            "SELECT message_id, chat_jid, sender_jid, sender_name, text, timestamp, from_me, is_ai \
+             FROM {TABLE_MESSAGES} WHERE chat_jid = ?1 \
+             ORDER BY timestamp DESC, rowid DESC LIMIT ?2"
+        );
+        let limit = i64::try_from(limit).unwrap_or(i64::MAX);
+
+        let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
+        let mut messages = statement
+            .query_map(params![chat_jid, limit], message_from_row)
+            .with_context(context)?
+            .collect::<rusqlite::Result<Vec<_>>>()
+            .with_context(context)?;
+        messages.reverse();
+
+        Ok(messages)
+    }
+
+    /// Purpose: list all chats for the UI.
+    /// Inputs: none.
+    /// Output: chats ordered by `last_timestamp` descending; name = contact name, else chat
+    /// name, else the user part of the jid.
+    /// Side effects: none (read only).
+    pub fn list_chats(&self) -> Result<Vec<ChatSummary>> {
+        let context = || "list chats";
+        let sql = format!(
+            "SELECT c.jid, COALESCE(k.name, c.name), c.last_timestamp, c.autoreply \
+             FROM {TABLE_CHATS} c LEFT JOIN {TABLE_CONTACTS} k ON k.jid = c.jid \
+             ORDER BY c.last_timestamp DESC, c.jid"
+        );
+
+        let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
+        let chats = statement
+            .query_map([], chat_summary_from_row)
+            .with_context(context)?
+            .collect::<rusqlite::Result<Vec<_>>>()
+            .with_context(context)?;
+
+        Ok(chats)
+    }
+
+    /// Purpose: store or rename a contact.
+    /// Inputs: the contact; a blank name is ignored.
+    /// Output: `()`.
+    /// Side effects: upserts the contacts row unless the name is blank.
+    pub fn upsert_contact(&self, contact: &Contact) -> Result<()> {
+        if contact.name.trim().is_empty() {
+            return Ok(());
+        }
+        let sql = format!(
+            "INSERT INTO {TABLE_CONTACTS} (jid, name) VALUES (?1, ?2) \
+             ON CONFLICT(jid) DO UPDATE SET name = excluded.name"
+        );
+
+        self.connection
+            .execute(&sql, params![contact.jid, contact.name])
+            .with_context(|| format!("upsert contact {}", contact.jid))?;
+
+        Ok(())
+    }
+
+    /// Purpose: list all known contacts.
+    /// Inputs: none.
+    /// Output: contacts ordered by name, case-insensitive.
+    /// Side effects: none (read only).
+    pub fn list_contacts(&self) -> Result<Vec<Contact>> {
+        let context = || "list contacts";
+        let sql = format!(
+            "SELECT jid, name FROM {TABLE_CONTACTS} ORDER BY name COLLATE NOCASE, jid"
+        );
+
+        let mut statement = self.connection.prepare_cached(&sql).with_context(context)?;
+        let contacts = statement
+            .query_map([], |row| {
+                Ok(Contact {
+                    jid: row.get(0)?,
+                    name: row.get(1)?,
+                })
+            })
+            .with_context(context)?
+            .collect::<rusqlite::Result<Vec<_>>>()
+            .with_context(context)?;
+
+        Ok(contacts)
+    }
+
+    /// Purpose: resolve the human-readable name of a jid.
+    /// Inputs: `jid` (chat or contact).
+    /// Output: contact name, else chat name, else the user part of the jid.
+    /// Side effects: none (read only).
+    pub fn display_name(&self, jid: &str) -> Result<String> {
+        let sql = format!(
+            "SELECT COALESCE((SELECT name FROM {TABLE_CONTACTS} WHERE jid = ?1), \
+                             (SELECT name FROM {TABLE_CHATS} WHERE jid = ?1))"
+        );
+        let stored_name: Option<String> = self
+            .connection
+            .query_row(&sql, params![jid], |row| row.get(0))
+            .with_context(|| format!("resolve display name of {jid}"))?;
+
+        Ok(stored_name.unwrap_or_else(|| jid_user_part(jid).to_string()))
+    }
+
+    /// Purpose: enable or disable the AI autoreply for a chat.
+    /// Inputs: `chat_jid` and the new state.
+    /// Output: `()`.
+    /// Side effects: upserts the chats row (created with default values if missing).
+    pub fn set_autoreply(&self, chat_jid: &str, enabled: bool) -> Result<()> {
+        let sql = format!(
+            "INSERT INTO {TABLE_CHATS} (jid, autoreply) VALUES (?1, ?2) \
+             ON CONFLICT(jid) DO UPDATE SET autoreply = excluded.autoreply"
+        );
+
+        self.connection
+            .execute(&sql, params![chat_jid, enabled])
+            .with_context(|| format!("set autoreply={enabled} for chat {chat_jid}"))?;
+
+        Ok(())
+    }
+
+    /// Purpose: tell whether the AI autoreply is enabled for a chat.
+    /// Inputs: `chat_jid`.
+    /// Output: the stored flag, or the default (`false`) if the chat has no row.
+    /// Side effects: none (read only).
+    pub fn is_autoreply(&self, chat_jid: &str) -> Result<bool> {
+        let sql = format!("SELECT autoreply FROM {TABLE_CHATS} WHERE jid = ?1");
+        let enabled: Option<bool> = self
+            .connection
+            .query_row(&sql, params![chat_jid], |row| row.get(0))
+            .optional()
+            .with_context(|| format!("read autoreply of chat {chat_jid}"))?;
+
+        Ok(enabled.unwrap_or(DEFAULT_AUTOREPLY))
+    }
+}
+
+/// Purpose: build the `CREATE TABLE/INDEX IF NOT EXISTS` script of the whole schema.
+/// Inputs: none.
+/// Output: the SQL script.
+/// Side effects: none.
+fn schema_sql() -> String {
+    format!(
+        "CREATE TABLE IF NOT EXISTS {TABLE_MESSAGES} (
+            message_id  TEXT PRIMARY KEY,
+            chat_jid    TEXT NOT NULL,
+            sender_jid  TEXT NOT NULL,
+            sender_name TEXT,
+            text        TEXT NOT NULL,
+            timestamp   INTEGER NOT NULL,
+            from_me     INTEGER NOT NULL,
+            is_ai       INTEGER NOT NULL
+        );
+        CREATE INDEX IF NOT EXISTS {INDEX_MESSAGES_BY_CHAT}
+            ON {TABLE_MESSAGES} (chat_jid, timestamp);
+        CREATE TABLE IF NOT EXISTS {TABLE_CHATS} (
+            jid            TEXT PRIMARY KEY,
+            name           TEXT,
+            last_timestamp INTEGER NOT NULL DEFAULT {DEFAULT_LAST_TIMESTAMP},
+            autoreply      INTEGER NOT NULL DEFAULT {default_autoreply}
+        );
+        CREATE TABLE IF NOT EXISTS {TABLE_CONTACTS} (
+            jid  TEXT PRIMARY KEY,
+            name TEXT NOT NULL
+        );",
+        default_autoreply = i64::from(DEFAULT_AUTOREPLY),
+    )
+}
+
+/// Purpose: insert a message unless its id is already stored.
+/// Inputs: a connection (or transaction) and the message.
+/// Output: `true` if a row was inserted, `false` if ignored as duplicate.
+/// Side effects: writes into messages.
+fn insert_message(connection: &Connection, message: &StoredMessage) -> rusqlite::Result<bool> {
+    let sql = format!(
+        "INSERT OR IGNORE INTO {TABLE_MESSAGES} \
+         (message_id, chat_jid, sender_jid, sender_name, text, timestamp, from_me, is_ai) \
+         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
+    );
+    let changed = connection.execute(
+        &sql,
+        params![
+            message.message_id,
+            message.chat_jid,
+            message.sender_jid,
+            message.sender_name,
+            message.text,
+            message.timestamp,
+            message.from_me,
+            message.is_ai,
+        ],
+    )?;
+
+    Ok(changed > 0)
+}
+
+/// Purpose: upsert the chat row touched by a newly stored message.
+/// Inputs: a connection (or transaction) and the message.
+/// Output: `()`.
+/// Side effects: writes into chats; keeps the max `last_timestamp` and never overwrites an
+/// existing non-empty name.
+fn upsert_chat_for_message(connection: &Connection, message: &StoredMessage) -> rusqlite::Result<()> {
+    let sql = format!(
+        "INSERT INTO {TABLE_CHATS} (jid, name, last_timestamp) VALUES (?1, ?2, ?3) \
+         ON CONFLICT(jid) DO UPDATE SET \
+             last_timestamp = MAX(last_timestamp, excluded.last_timestamp), \
+             name = COALESCE(NULLIF(name, ''), excluded.name)"
+    );
+    connection.execute(
+        &sql,
+        params![message.chat_jid, chat_name_candidate(message), message.timestamp],
+    )?;
+
+    Ok(())
+}
+
+/// Purpose: pick the chat name a message may provide.
+/// Inputs: the message.
+/// Output: the trimmed sender push name for incoming messages, `None` otherwise or if blank.
+/// Side effects: none.
+fn chat_name_candidate(message: &StoredMessage) -> Option<&str> {
+    if message.from_me {
+        return None;
+    }
+
+    message
+        .sender_name
+        .as_deref()
+        .map(str::trim)
+        .filter(|name| !name.is_empty())
+}
+
+/// Purpose: extract the user part of a jid (text before `@`).
+/// Inputs: `jid`.
+/// Output: the user part, or the whole jid if it has no separator.
+/// Side effects: none.
+fn jid_user_part(jid: &str) -> &str {
+    jid.split_once(JID_SEPARATOR).map_or(jid, |(user, _)| user)
+}
+
+/// Purpose: map a messages row (columns in `recent_messages` order) to a `StoredMessage`.
+/// Inputs: the SQLite row.
+/// Output: the message, or the column conversion error.
+/// Side effects: none.
+fn message_from_row(row: &Row<'_>) -> rusqlite::Result<StoredMessage> {
+    Ok(StoredMessage {
+        message_id: row.get(0)?,
+        chat_jid: row.get(1)?,
+        sender_jid: row.get(2)?,
+        sender_name: row.get(3)?,
+        text: row.get(4)?,
+        timestamp: row.get(5)?,
+        from_me: row.get(6)?,
+        is_ai: row.get(7)?,
+    })
+}
+
+/// Purpose: map a chats row (jid, resolved name, last_timestamp, autoreply) to a `ChatSummary`.
+/// Inputs: the SQLite row; a NULL name falls back to the user part of the jid.
+/// Output: the summary, or the column conversion error.
+/// Side effects: none.
+fn chat_summary_from_row(row: &Row<'_>) -> rusqlite::Result<ChatSummary> {
+    let jid: String = row.get(0)?;
+    let stored_name: Option<String> = row.get(1)?;
+    let name = stored_name.unwrap_or_else(|| jid_user_part(&jid).to_string());
+
+    Ok(ChatSummary {
+        jid,
+        name,
+        last_timestamp: row.get(2)?,
+        autoreply: row.get(3)?,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const CHAT_A: &str = "393331111111@s.whatsapp.net";
+    const CHAT_B: &str = "393332222222@s.whatsapp.net";
+    const GROUP: &str = "120363000000@g.us";
+
+    /// Builds an incoming message with the given id, chat and timestamp.
+    fn message(message_id: &str, chat_jid: &str, timestamp: i64) -> StoredMessage {
+        StoredMessage {
+            message_id: message_id.to_string(),
+            chat_jid: chat_jid.to_string(),
+            sender_jid: chat_jid.to_string(),
+            sender_name: None,
+            text: format!("text {message_id}"),
+            timestamp,
+            from_me: false,
+            is_ai: false,
+        }
+    }
+
+    /// Builds a contact from jid and name.
+    fn contact(jid: &str, name: &str) -> Contact {
+        Contact {
+            jid: jid.to_string(),
+            name: name.to_string(),
+        }
+    }
+
+    #[test]
+    fn duplicate_message_id_is_ignored() {
+        let history = History::open_in_memory().unwrap();
+        let original = message("m1", CHAT_A, 10);
+        let mut duplicate = message("m1", CHAT_A, 20);
+        duplicate.text = "changed".to_string();
+
+        assert!(history.save_message(&original).unwrap());
+        assert!(!history.save_message(&duplicate).unwrap());
+
+        let stored = history.recent_messages(CHAT_A, 10).unwrap();
+        assert_eq!(stored, vec![original]);
+    }
+
+    #[test]
+    fn recent_messages_respects_limit_and_ascending_order() {
+        let history = History::open_in_memory().unwrap();
+        for (id, timestamp) in [("m3", 30), ("m1", 10), ("m4", 40), ("m2", 20)] {
+            history.save_message(&message(id, CHAT_A, timestamp)).unwrap();
+        }
+        history.save_message(&message("other", CHAT_B, 50)).unwrap();
+
+        let ids: Vec<String> = history
+            .recent_messages(CHAT_A, 3)
+            .unwrap()
+            .into_iter()
+            .map(|stored| stored.message_id)
+            .collect();
+
+        assert_eq!(ids, ["m2", "m3", "m4"]);
+        assert!(history.recent_messages(CHAT_A, 0).unwrap().is_empty());
+    }
+
+    #[test]
+    fn message_flags_and_sender_name_roundtrip() {
+        let history = History::open_in_memory().unwrap();
+        let mut sent = message("m1", CHAT_A, 10);
+        sent.from_me = true;
+        sent.is_ai = true;
+        sent.sender_name = Some("Me".to_string());
+        history.save_message(&sent).unwrap();
+
+        assert_eq!(history.recent_messages(CHAT_A, 1).unwrap(), vec![sent]);
+    }
+
+    #[test]
+    fn list_chats_orders_by_last_timestamp_desc_and_keeps_max() {
+        let history = History::open_in_memory().unwrap();
+        history.save_message(&message("a1", CHAT_A, 100)).unwrap();
+        history.save_message(&message("b1", CHAT_B, 200)).unwrap();
+        history.save_message(&message("a0", CHAT_A, 50)).unwrap();
+
+        let chats = history.list_chats().unwrap();
+        let summary: Vec<(&str, i64)> = chats
+            .iter()
+            .map(|chat| (chat.jid.as_str(), chat.last_timestamp))
+            .collect();
+
+        assert_eq!(summary, [(CHAT_B, 200), (CHAT_A, 100)]);
+    }
+
+    #[test]
+    fn chat_name_falls_back_contact_then_chat_name_then_jid_user_part() {
+        let history = History::open_in_memory().unwrap();
+        history.save_message(&message("a1", CHAT_A, 30)).unwrap();
+        let mut named = message("b1", CHAT_B, 20);
+        named.sender_name = Some("Push Bob".to_string());
+        history.save_message(&named).unwrap();
+        history.save_message(&message("g1", GROUP, 10)).unwrap();
+
+        let names = |history: &History| -> Vec<String> {
+            history.list_chats().unwrap().into_iter().map(|chat| chat.name).collect()
+        };
+        assert_eq!(names(&history), ["393331111111", "Push Bob", "120363000000"]);
+
+        history.upsert_contact(&contact(CHAT_B, "Roberto")).unwrap();
+        assert_eq!(names(&history), ["393331111111", "Roberto", "120363000000"]);
+        assert_eq!(history.display_name(CHAT_A).unwrap(), "393331111111");
+        assert_eq!(history.display_name(CHAT_B).unwrap(), "Roberto");
+        assert_eq!(history.display_name("unknown@s.whatsapp.net").unwrap(), "unknown");
+    }
+
+    #[test]
+    fn chat_name_is_set_once_and_never_from_own_messages() {
+        let history = History::open_in_memory().unwrap();
+        let mut own = message("m1", CHAT_A, 10);
+        own.from_me = true;
+        own.sender_name = Some("Me".to_string());
+        history.save_message(&own).unwrap();
+        assert_eq!(history.display_name(CHAT_A).unwrap(), "393331111111");
+
+        let mut first = message("m2", CHAT_A, 20);
+        first.sender_name = Some("Alice".to_string());
+        let mut second = message("m3", CHAT_A, 30);
+        second.sender_name = Some("Alice Renamed".to_string());
+        history.save_message(&first).unwrap();
+        history.save_message(&second).unwrap();
+
+        assert_eq!(history.display_name(CHAT_A).unwrap(), "Alice");
+    }
+
+    #[test]
+    fn autoreply_defaults_to_false_and_toggles() {
+        let history = History::open_in_memory().unwrap();
+        assert!(!history.is_autoreply(CHAT_A).unwrap());
+
+        history.set_autoreply(CHAT_A, true).unwrap();
+        assert!(history.is_autoreply(CHAT_A).unwrap());
+        assert!(!history.is_autoreply(CHAT_B).unwrap());
+
+        history.set_autoreply(CHAT_A, false).unwrap();
+        assert!(!history.is_autoreply(CHAT_A).unwrap());
+    }
+
+    #[test]
+    fn autoreply_survives_incoming_messages_and_shows_in_chat_list() {
+        let history = History::open_in_memory().unwrap();
+        history.set_autoreply(CHAT_A, true).unwrap();
+        history.save_message(&message("m1", CHAT_A, 10)).unwrap();
+
+        let chats = history.list_chats().unwrap();
+        assert_eq!(chats.len(), 1);
+        assert!(chats[0].autoreply);
+        assert_eq!(chats[0].last_timestamp, 10);
+    }
+
+    #[test]
+    fn list_contacts_is_ordered_case_insensitive() {
+        let history = History::open_in_memory().unwrap();
+        history.upsert_contact(&contact("3@s.whatsapp.net", "bruno")).unwrap();
+        history.upsert_contact(&contact("1@s.whatsapp.net", "Carla")).unwrap();
+        history.upsert_contact(&contact("2@s.whatsapp.net", "Anna")).unwrap();
+
+        let names: Vec<String> = history
+            .list_contacts()
+            .unwrap()
+            .into_iter()
+            .map(|stored| stored.name)
+            .collect();
+
+        assert_eq!(names, ["Anna", "bruno", "Carla"]);
+    }
+
+    #[test]
+    fn upsert_contact_ignores_blank_names_and_renames_existing() {
+        let history = History::open_in_memory().unwrap();
+        history.upsert_contact(&contact(CHAT_A, "")).unwrap();
+        history.upsert_contact(&contact(CHAT_A, "   ")).unwrap();
+        assert!(history.list_contacts().unwrap().is_empty());
+
+        history.upsert_contact(&contact(CHAT_A, "Anna")).unwrap();
+        history.upsert_contact(&contact(CHAT_A, "")).unwrap();
+        history.upsert_contact(&contact(CHAT_A, "Anna B")).unwrap();
+        assert_eq!(history.list_contacts().unwrap(), vec![contact(CHAT_A, "Anna B")]);
+    }
+
+    #[test]
+    fn open_persists_data_on_disk_and_creates_parent_directory() {
+        let directory = std::env::temp_dir().join(format!("wh-history-test-{}", std::process::id()));
+        let path = directory.join("nested").join("history.db");
+        {
+            let history = History::open(&path).unwrap();
+            history.save_message(&message("m1", CHAT_A, 10)).unwrap();
+        }
+
+        let reopened = History::open(&path).unwrap();
+        assert_eq!(reopened.recent_messages(CHAT_A, 10).unwrap().len(), 1);
+
+        drop(reopened);
+        std::fs::remove_dir_all(&directory).unwrap();
+    }
+}

+ 147 - 0
src/main.rs

@@ -0,0 +1,147 @@
+//! Entry point of `wh`: wires configuration, storage, WhatsApp and the TUI event loop.
+
+mod ai;
+mod app;
+mod autoreply;
+mod config;
+mod history;
+mod ui;
+mod whatsapp;
+
+use anyhow::{Context, Result};
+use ratatui::DefaultTerminal;
+use ratatui::crossterm::event::{self, Event, KeyEventKind};
+use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
+
+use crate::app::{App, AppDeps, AppEvent};
+use crate::config::Config;
+use crate::history::History;
+use crate::whatsapp::WaEvent;
+
+/// Name of the thread that reads terminal input events.
+const INPUT_THREAD_NAME: &str = "wh-input";
+
+/// Purpose: program entry point; builds the application and runs it until quit.
+/// Inputs: none (reads config.toml and terminal events).
+/// Output: `Ok(())` on a clean exit, otherwise the first fatal error.
+/// Side effects: creates the data directory and databases, connects to WhatsApp, takes over the terminal.
+#[tokio::main]
+async fn main() -> Result<()> {
+    let config = Config::load().context("cannot load configuration")?;
+    config.ensure_data_dir()?;
+    let history = History::open(&config.history_db_path()).context("cannot open history database")?;
+
+    let (app_tx, app_rx) = unbounded_channel::<AppEvent>();
+    let (wa_tx, wa_rx) = unbounded_channel::<WaEvent>();
+
+    let wa = whatsapp::start(&config.session_db_path(), wa_tx)
+        .await
+        .context("cannot start WhatsApp client")?;
+    spawn_wa_forwarder(wa_rx, app_tx.clone());
+
+    let app = App::new(AppDeps {
+        config,
+        history,
+        wa,
+        events: app_tx.clone(),
+    })?;
+    spawn_input_thread(app_tx)?;
+
+    run_tui(app, app_rx).await
+}
+
+/// Purpose: forward WhatsApp events into the application event channel.
+/// Inputs: `wa_rx`, the receiver of WhatsApp events; `app_tx`, the application event sender.
+/// Output: none.
+/// Side effects: spawns a tokio task that ends when either channel side is closed.
+fn spawn_wa_forwarder(mut wa_rx: UnboundedReceiver<WaEvent>, app_tx: UnboundedSender<AppEvent>) {
+    tokio::spawn(async move {
+        while let Some(event) = wa_rx.recv().await {
+            if app_tx.send(AppEvent::Wa(event)).is_err() {
+                break;
+            }
+        }
+    });
+}
+
+/// Purpose: start the dedicated thread that reads blocking terminal events.
+/// Inputs: `app_tx`, the application event sender.
+/// Output: `Ok(())` if the thread was spawned, otherwise the spawn error.
+/// Side effects: spawns a detached OS thread; it ends when the receiver is dropped or reading fails.
+fn spawn_input_thread(app_tx: UnboundedSender<AppEvent>) -> Result<()> {
+    std::thread::Builder::new()
+        .name(INPUT_THREAD_NAME.to_owned())
+        .spawn(move || input_loop(&app_tx))
+        .context("cannot spawn input thread")?;
+    Ok(())
+}
+
+/// Purpose: read terminal events and forward them until the receiver is gone or reading fails.
+/// Inputs: `app_tx`, the application event sender.
+/// Output: none.
+/// Side effects: blocks on terminal input; on a read error it reports the error to the app and stops,
+/// because a failing terminal reader would otherwise spin forever.
+fn input_loop(app_tx: &UnboundedSender<AppEvent>) {
+    loop {
+        match event::read() {
+            Ok(raw) => {
+                if let Some(mapped) = map_terminal_event(raw)
+                    && app_tx.send(mapped).is_err()
+                {
+                    return;
+                }
+            }
+            Err(err) => {
+                let message = format!("terminal input error: {err}");
+                // Ignoring a send failure is fine here: it means the app is already shutting down.
+                let _ = app_tx.send(AppEvent::Wa(WaEvent::Error(message)));
+                return;
+            }
+        }
+    }
+}
+
+/// Purpose: convert a raw terminal event into an application event.
+/// Inputs: `raw`, the event read from the terminal.
+/// Output: `Some(AppEvent)` for key presses and resizes, `None` for everything else
+/// (key releases and repeats are dropped because Windows also emits them).
+/// Side effects: none.
+fn map_terminal_event(raw: Event) -> Option<AppEvent> {
+    match raw {
+        Event::Key(key) if key.kind == KeyEventKind::Press => Some(AppEvent::Key(key)),
+        Event::Resize(_, _) => Some(AppEvent::Redraw),
+        _ => None,
+    }
+}
+
+/// Purpose: run the TUI and guarantee the terminal is restored on every exit path.
+/// Inputs: `app`, the application state; `app_rx`, the receiver of application events.
+/// Output: `Ok(())` after a clean quit, otherwise the loop error (after restoring the terminal).
+/// Side effects: enters and leaves raw mode and the alternate screen.
+async fn run_tui(app: App, app_rx: UnboundedReceiver<AppEvent>) -> Result<()> {
+    let mut terminal = ratatui::init();
+    let result = event_loop(&mut terminal, app, app_rx).await;
+    ratatui::restore();
+    result
+}
+
+/// Purpose: draw the UI and dispatch events until quit or the event channel closes.
+/// Inputs: `terminal`, the active terminal; `app`, the application state; `app_rx`, the event receiver.
+/// Output: `Ok(())` on quit or channel close, otherwise a draw or event-handling error.
+/// Side effects: draws to the terminal and mutates `app`.
+async fn event_loop(
+    terminal: &mut DefaultTerminal,
+    mut app: App,
+    mut app_rx: UnboundedReceiver<AppEvent>,
+) -> Result<()> {
+    while !app.should_quit {
+        terminal
+            .draw(|frame| ui::render(frame, &app))
+            .context("cannot draw frame")?;
+        let Some(event) = app_rx.recv().await else {
+            break;
+        };
+        app.handle_event(event)?;
+    }
+    Ok(())
+}

+ 636 - 0
src/ui.rs

@@ -0,0 +1,636 @@
+//! ratatui rendering of the whole application (connecting, pairing and main screens).
+//!
+//! Rendering is a pure function of `&App`: nothing here mutates application state.
+
+use std::fmt::Display;
+
+use chrono::{DateTime, Local, TimeZone};
+use qrcode::QrCode;
+use qrcode::render::unicode::Dense1x2;
+use qrcode::types::QrError;
+use ratatui::Frame;
+use ratatui::layout::{Alignment, Constraint, Layout, Rect};
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span, Text};
+use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph};
+
+use crate::app::{App, Focus, ListMode, Screen};
+use crate::history::{ChatSummary, Contact, StoredMessage};
+
+/// Width of the chat/contact list pane, as a percentage of the terminal width.
+const LIST_PANE_PERCENT: u16 = 30;
+/// Height of the input box (two border rows plus one text row).
+const INPUT_PANE_HEIGHT: u16 = 3;
+/// Height of the bottom status/help line.
+const FOOTER_HEIGHT: u16 = 1;
+
+/// Title of the list pane while showing chats.
+const TITLE_CHATS: &str = " Chat [F2: Contatti] ";
+/// Title of the list pane while showing contacts.
+const TITLE_CONTACTS: &str = " Contatti [F2: Chat] ";
+/// Title of the messages pane when no chat is open.
+const TITLE_NO_CHAT: &str = " Nessuna chat aperta ";
+/// Title of the input pane.
+const TITLE_INPUT: &str = " Messaggio ";
+/// Separator between the chat name and the AI state in the messages pane title.
+const TITLE_SEPARATOR: &str = "──";
+/// Label of the autoreply state in the messages pane title.
+const AI_LABEL: &str = "AI";
+/// Autoreply enabled value shown in the messages pane title.
+const AI_ON: &str = "ON";
+/// Autoreply disabled value shown in the messages pane title.
+const AI_OFF: &str = "OFF";
+/// Marker appended to chat rows whose autoreply is on.
+const AI_ROW_MARKER: &str = " ●AI";
+/// Symbol drawn in front of the selected list row.
+const HIGHLIGHT_SYMBOL: &str = "> ";
+/// Prompt shown in front of the text being typed.
+const INPUT_PROMPT: &str = "> ";
+/// Key help shown at the right of the status text.
+const KEY_HELP: &str =
+    "Tab:focus  Enter:apri/invia  F2:chat/contatti  Ctrl+A:AI chat  Ctrl+C:esci";
+/// Gap between the status text and the key help.
+const FOOTER_GAP: &str = "   ";
+/// Leading space of the footer line.
+const FOOTER_MARGIN: &str = " ";
+
+/// Text of the connecting screen.
+const TEXT_CONNECTING: &str = "Connessione a WhatsApp in corso...";
+/// Title of the pairing screen.
+const TEXT_PAIRING_TITLE: &str = "Collega WhatsApp: scansiona il codice QR";
+/// Pairing instructions.
+const TEXT_PAIRING_STEPS: &str =
+    "WhatsApp > Impostazioni > Dispositivi collegati > Collega un dispositivo";
+/// Shown on the pairing screen while no QR code has been received yet.
+const TEXT_QR_WAITING: &str = "In attesa del codice QR...";
+/// Shown instead of the QR code when the terminal is too small to display it.
+const TEXT_QR_TOO_SMALL: &str = "Terminale troppo piccolo per il QR: ingrandisci la finestra";
+/// Prefix of the QR encoding error message.
+const TEXT_QR_ERROR: &str = "Errore generazione QR";
+/// Terminal rows used by the pairing screen besides the QR (title, 2 blanks, steps, status).
+const PAIRING_TEXT_ROWS: usize = 5;
+
+/// Sender label of messages sent from this account.
+const SENDER_ME: &str = "Tu";
+/// Prefix of messages generated by the AI.
+const AI_PREFIX: &str = "[AI] ";
+/// Separator between the user part and the server part of a jid.
+const JID_SEPARATOR: char = '@';
+/// `chrono` format of the message time.
+const CLOCK_FORMAT: &str = "%H:%M";
+/// Time shown when a timestamp is out of range.
+const CLOCK_PLACEHOLDER: &str = "--:--";
+
+/// Border color of the focused pane.
+const FOCUS_COLOR: Color = Color::Yellow;
+/// Color of the autoreply marker.
+const AI_MARKER_COLOR: Color = Color::Green;
+/// Color of messages written by the AI.
+const AI_MESSAGE_COLOR: Color = Color::Cyan;
+/// Color of messages sent by the user.
+const OWN_MESSAGE_COLOR: Color = Color::Green;
+
+/// Purpose: draw the whole UI for the current application state.
+/// Inputs: the ratatui `frame` to draw on and the read-only `app` state.
+/// Output: none (the frame buffer is filled).
+/// Side effects: writes to the frame buffer; may set the terminal cursor position.
+pub fn render(frame: &mut Frame, app: &App) {
+    let area = frame.area();
+    match app.screen {
+        Screen::Connecting => render_connecting(frame, area, app),
+        Screen::Pairing => render_pairing(frame, area, app),
+        Screen::Main => render_main(frame, area, app),
+    }
+}
+
+/// Purpose: draw the "connecting" screen.
+/// Inputs: frame, the area to fill and the app (status text).
+/// Output: none.
+/// Side effects: writes to the frame buffer.
+fn render_connecting(frame: &mut Frame, area: Rect, app: &App) {
+    let lines = vec![
+        Line::styled(TEXT_CONNECTING, Style::new().add_modifier(Modifier::BOLD)),
+        Line::raw(""),
+        Line::raw(app.status.clone()),
+    ];
+    render_centered(frame, area, lines);
+}
+
+/// Purpose: draw the QR pairing screen.
+/// Inputs: frame, the area to fill and the app (QR payload and status).
+/// Output: none.
+/// Side effects: writes to the frame buffer.
+fn render_pairing(frame: &mut Frame, area: Rect, app: &App) {
+    let max_rows = usize::from(area.height).saturating_sub(PAIRING_TEXT_ROWS);
+    let qr_lines = qr_block_lines(app.qr_code.as_deref(), max_rows, usize::from(area.width));
+
+    let mut lines = vec![
+        Line::styled(TEXT_PAIRING_TITLE, Style::new().add_modifier(Modifier::BOLD)),
+        Line::raw(""),
+    ];
+    lines.extend(qr_lines);
+    lines.push(Line::raw(""));
+    lines.push(Line::raw(TEXT_PAIRING_STEPS));
+    lines.push(Line::raw(app.status.clone()));
+    render_centered(frame, area, lines);
+}
+
+/// Purpose: render centered text lines in the middle of an area.
+/// Inputs: frame, the area and the lines to show.
+/// Output: none.
+/// Side effects: writes to the frame buffer.
+fn render_centered(frame: &mut Frame, area: Rect, lines: Vec<Line<'static>>) {
+    let height = u16::try_from(lines.len()).unwrap_or(u16::MAX);
+    let [_, middle, _] = Layout::vertical([
+        Constraint::Fill(1),
+        Constraint::Length(height),
+        Constraint::Fill(1),
+    ])
+    .areas(area);
+
+    frame.render_widget(
+        Paragraph::new(Text::from(lines)).alignment(Alignment::Center),
+        middle,
+    );
+}
+
+/// Purpose: build the lines showing the QR code, or a message explaining why it is missing.
+/// Inputs: the QR `payload` (if any) and the rows/columns available for the QR.
+/// Output: the QR rows, or a single informative line (waiting, too small, encoding error).
+/// Side effects: none.
+fn qr_block_lines(payload: Option<&str>, max_rows: usize, max_cols: usize) -> Vec<Line<'static>> {
+    let Some(payload) = payload else {
+        return vec![Line::raw(TEXT_QR_WAITING)];
+    };
+    match qr_to_text(payload) {
+        Err(error) => vec![Line::raw(format!("{TEXT_QR_ERROR}: {error}"))],
+        Ok(text) => {
+            let cols = text.lines().next().map_or(0, |row| row.chars().count());
+            if text.lines().count() > max_rows || cols > max_cols {
+                return vec![Line::raw(TEXT_QR_TOO_SMALL)];
+            }
+            text.lines().map(|row| Line::raw(row.to_string())).collect()
+        }
+    }
+}
+
+/// Purpose: encode a payload as a QR code drawn with half-block characters.
+/// Inputs: the `payload` to encode.
+/// Output: the multi-line text (with quiet zone), or the encoding error. Dark modules are
+/// drawn as empty cells and light modules as filled blocks so it scans on a dark terminal.
+/// Side effects: none.
+pub fn qr_to_text(payload: &str) -> Result<String, QrError> {
+    let code = QrCode::new(payload.as_bytes())?;
+
+    Ok(code
+        .render::<Dense1x2>()
+        .dark_color(Dense1x2::Light)
+        .light_color(Dense1x2::Dark)
+        .quiet_zone(true)
+        .build())
+}
+
+/// Purpose: draw the main screen (list pane, messages, input, footer).
+/// Inputs: frame, the area to fill and the app.
+/// Output: none.
+/// Side effects: writes to the frame buffer; may set the cursor position.
+fn render_main(frame: &mut Frame, area: Rect, app: &App) {
+    let [body, footer] =
+        Layout::vertical([Constraint::Fill(1), Constraint::Length(FOOTER_HEIGHT)]).areas(area);
+    let [list, right] = Layout::horizontal([
+        Constraint::Percentage(LIST_PANE_PERCENT),
+        Constraint::Fill(1),
+    ])
+    .areas(body);
+    let [messages, input] =
+        Layout::vertical([Constraint::Fill(1), Constraint::Length(INPUT_PANE_HEIGHT)]).areas(right);
+
+    render_list_pane(frame, list, app);
+    render_messages_pane(frame, messages, app);
+    render_input_pane(frame, input, app);
+    render_footer(frame, footer, app);
+}
+
+/// Purpose: build a bordered block whose border is highlighted when focused.
+/// Inputs: the `title` and whether the pane has the focus.
+/// Output: the block.
+/// Side effects: none.
+fn pane_block(title: String, focused: bool) -> Block<'static> {
+    let border = if focused {
+        Style::new().fg(FOCUS_COLOR)
+    } else {
+        Style::new()
+    };
+
+    Block::bordered().title(title).border_style(border)
+}
+
+/// Purpose: draw the chats/contacts list with the selection highlighted.
+/// Inputs: frame, the pane area and the app.
+/// Output: none.
+/// Side effects: writes to the frame buffer.
+fn render_list_pane(frame: &mut Frame, area: Rect, app: &App) {
+    let (title, items): (&str, Vec<ListItem>) = match app.list_mode {
+        ListMode::Chats => (TITLE_CHATS, app.chats.iter().map(chat_item).collect()),
+        ListMode::Contacts => (TITLE_CONTACTS, app.contacts.iter().map(contact_item).collect()),
+    };
+    let len = items.len();
+    let list = List::new(items)
+        .block(pane_block(title.to_string(), app.focus == Focus::List))
+        .highlight_style(Style::new().add_modifier(Modifier::REVERSED))
+        .highlight_symbol(HIGHLIGHT_SYMBOL);
+    let mut state = ListState::default().with_selected(selected_index(len, app.selected));
+
+    frame.render_stateful_widget(list, area, &mut state);
+}
+
+/// Purpose: compute the row to highlight in a list.
+/// Inputs: the list length `len` and the requested `selected` row.
+/// Output: `selected` clamped to the last row, or `None` if the list is empty.
+/// Side effects: none.
+fn selected_index(len: usize, selected: usize) -> Option<usize> {
+    len.checked_sub(1).map(|last| selected.min(last))
+}
+
+/// Purpose: build the list row of a chat (name plus AI marker when autoreply is on).
+/// Inputs: the chat.
+/// Output: the list item.
+/// Side effects: none.
+fn chat_item(chat: &ChatSummary) -> ListItem<'static> {
+    let mut spans = vec![Span::raw(chat.name.clone())];
+    if chat.autoreply {
+        spans.push(Span::styled(AI_ROW_MARKER, Style::new().fg(AI_MARKER_COLOR)));
+    }
+
+    ListItem::new(Line::from(spans))
+}
+
+/// Purpose: build the list row of a contact.
+/// Inputs: the contact.
+/// Output: the list item.
+/// Side effects: none.
+fn contact_item(contact: &Contact) -> ListItem<'static> {
+    ListItem::new(Line::raw(contact.name.clone()))
+}
+
+/// Purpose: build the title of the messages pane.
+/// Inputs: the `open_chat` identifier (if any), its display `name` and its `autoreply` state.
+/// Output: chat name with "AI: ON/OFF", or "Nessuna chat aperta" when no chat is open.
+/// Side effects: none.
+fn messages_title(open_chat: Option<&str>, name: &str, autoreply: bool) -> String {
+    if open_chat.is_none() {
+        return TITLE_NO_CHAT.to_string();
+    }
+    let state = if autoreply { AI_ON } else { AI_OFF };
+
+    format!(" {name} {TITLE_SEPARATOR} {AI_LABEL}: {state} ")
+}
+
+/// Purpose: draw the messages of the open chat, keeping the latest lines visible.
+/// Inputs: frame, the pane area and the app.
+/// Output: none.
+/// Side effects: writes to the frame buffer.
+fn render_messages_pane(frame: &mut Frame, area: Rect, app: &App) {
+    let title = messages_title(app.open_chat.as_deref(), &app.open_chat_name, app.open_chat_autoreply);
+    let block = pane_block(title, false);
+    let inner = block.inner(area);
+    let lines = visible_message_lines(&app.messages, inner.width, inner.height);
+
+    frame.render_widget(Paragraph::new(Text::from(lines)).block(block), area);
+}
+
+/// Purpose: wrap all messages to a width and keep only the last rows that fit.
+/// Inputs: the messages (oldest first) and the `width`/`height` of the text area in cells.
+/// Output: at most `height` lines, being the most recent ones.
+/// Side effects: none.
+fn visible_message_lines(messages: &[StoredMessage], width: u16, height: u16) -> Vec<Line<'static>> {
+    let mut lines: Vec<Line<'static>> = messages
+        .iter()
+        .flat_map(|message| message_lines(message, usize::from(width)))
+        .collect();
+    let excess = lines.len().saturating_sub(usize::from(height));
+    lines.drain(..excess);
+
+    lines
+}
+
+/// Purpose: format, wrap and style a single message.
+/// Inputs: the message and the wrap `width` in cells.
+/// Output: the styled lines of the message.
+/// Side effects: none.
+fn message_lines(message: &StoredMessage, width: usize) -> Vec<Line<'static>> {
+    let style = message_style(message);
+
+    wrap_text(&format_message_line(message), width)
+        .into_iter()
+        .map(|row| Line::styled(row, style))
+        .collect()
+}
+
+/// Purpose: pick the text style of a message.
+/// Inputs: the message.
+/// Output: cyan for AI messages, green for own messages, default otherwise.
+/// Side effects: none.
+fn message_style(message: &StoredMessage) -> Style {
+    match (message.from_me, message.is_ai) {
+        (true, true) => Style::new().fg(AI_MESSAGE_COLOR),
+        (true, false) => Style::new().fg(OWN_MESSAGE_COLOR),
+        (false, _) => Style::new(),
+    }
+}
+
+/// Purpose: format a message as "HH:MM <sender>: text" using the local time zone.
+/// Inputs: the message.
+/// Output: the single-line (before wrapping) text.
+/// Side effects: reads the system time zone.
+pub fn format_message_line(message: &StoredMessage) -> String {
+    format_message_line_in(message, &Local)
+}
+
+/// Purpose: format a message as "HH:MM <sender>: text" in a given time zone.
+/// Inputs: the message and the time `zone` used for the clock.
+/// Output: the text; sender is "Tu" (prefixed by "[AI] " for AI messages) for own messages,
+/// else the sender name, else the user part of the sender jid.
+/// Side effects: none.
+fn format_message_line_in<Tz: TimeZone>(message: &StoredMessage, zone: &Tz) -> String
+where
+    Tz::Offset: Display,
+{
+    let time = format_clock_time(message.timestamp, zone);
+
+    format!("{time} {}: {}", sender_label(message), message.text)
+}
+
+/// Purpose: format a unix timestamp as a clock time.
+/// Inputs: `timestamp` in seconds and the time `zone`.
+/// Output: "HH:MM", or "--:--" if the timestamp is out of range.
+/// Side effects: none.
+fn format_clock_time<Tz: TimeZone>(timestamp: i64, zone: &Tz) -> String
+where
+    Tz::Offset: Display,
+{
+    DateTime::from_timestamp(timestamp, 0).map_or_else(
+        || CLOCK_PLACEHOLDER.to_string(),
+        |utc| utc.with_timezone(zone).format(CLOCK_FORMAT).to_string(),
+    )
+}
+
+/// Purpose: choose the sender label of a message.
+/// Inputs: the message.
+/// Output: "Tu" / "[AI] Tu" for own messages, else the non-blank sender name, else the user
+/// part of the sender jid.
+/// Side effects: none.
+fn sender_label(message: &StoredMessage) -> String {
+    if message.from_me {
+        let prefix = if message.is_ai { AI_PREFIX } else { "" };
+        return format!("{prefix}{SENDER_ME}");
+    }
+
+    message
+        .sender_name
+        .as_deref()
+        .map(str::trim)
+        .filter(|name| !name.is_empty())
+        .unwrap_or_else(|| jid_user_part(&message.sender_jid))
+        .to_string()
+}
+
+/// Purpose: extract the user part of a jid (text before `@`).
+/// Inputs: `jid`.
+/// Output: the user part, or the whole jid if it has no separator.
+/// Side effects: none.
+fn jid_user_part(jid: &str) -> &str {
+    jid.split_once(JID_SEPARATOR).map_or(jid, |(user, _)| user)
+}
+
+/// Purpose: measure the display width of a string in terminal cells.
+/// Inputs: the text.
+/// Output: the width in cells.
+/// Side effects: none.
+fn text_width(text: &str) -> usize {
+    Line::raw(text).width()
+}
+
+/// Purpose: measure the display width of a character in terminal cells.
+/// Inputs: the character.
+/// Output: the width in cells (0, 1 or 2).
+/// Side effects: none.
+fn char_width(ch: char) -> usize {
+    let mut buffer = [0u8; 4];
+
+    text_width(ch.encode_utf8(&mut buffer))
+}
+
+/// Purpose: wrap text to a width, honoring explicit newlines.
+/// Inputs: the `text` and the maximum `width` in cells (at least 1 is used).
+/// Output: the rows; control characters are dropped.
+/// Side effects: none.
+fn wrap_text(text: &str, width: usize) -> Vec<String> {
+    let width = width.max(1);
+    let mut rows = Vec::new();
+    for paragraph in text.split('\n') {
+        wrap_paragraph(paragraph, width, &mut rows);
+    }
+
+    rows
+}
+
+/// Purpose: wrap a single paragraph (no newlines) by characters.
+/// Inputs: the `paragraph`, the `width` in cells and the output `rows` to append to.
+/// Output: none.
+/// Side effects: appends at least one row to `rows`.
+fn wrap_paragraph(paragraph: &str, width: usize, rows: &mut Vec<String>) {
+    let mut row = String::new();
+    let mut used = 0;
+    for ch in paragraph.chars().filter(|ch| !ch.is_control()) {
+        let ch_width = char_width(ch);
+        if used + ch_width > width && !row.is_empty() {
+            rows.push(std::mem::take(&mut row));
+            used = 0;
+        }
+        row.push(ch);
+        used += ch_width;
+    }
+    rows.push(row);
+}
+
+/// Purpose: draw the input box and place the cursor when the input is focused.
+/// Inputs: frame, the pane area and the app.
+/// Output: none.
+/// Side effects: writes to the frame buffer; sets the cursor position if focus is Input.
+fn render_input_pane(frame: &mut Frame, area: Rect, app: &App) {
+    let focused = app.focus == Focus::Input;
+    let block = pane_block(TITLE_INPUT.to_string(), focused);
+    let inner = block.inner(area);
+    let typed = format!("{INPUT_PROMPT}{}", app.input);
+    let visible = tail_fitting(&typed, usize::from(inner.width).saturating_sub(1));
+    let cursor_offset = u16::try_from(text_width(visible)).unwrap_or(u16::MAX);
+
+    frame.render_widget(Paragraph::new(visible).block(block), area);
+    if focused {
+        frame.set_cursor_position((inner.x.saturating_add(cursor_offset), inner.y));
+    }
+}
+
+/// Purpose: keep the end of a text that fits a width (horizontal scroll of the input).
+/// Inputs: the `text` and the `max_width` in cells.
+/// Output: the longest suffix whose width is at most `max_width`.
+/// Side effects: none.
+fn tail_fitting(text: &str, max_width: usize) -> &str {
+    let mut used = 0;
+    let mut start = text.len();
+    for (index, ch) in text.char_indices().rev() {
+        used += char_width(ch);
+        if used > max_width {
+            break;
+        }
+        start = index;
+    }
+
+    &text[start..]
+}
+
+/// Purpose: draw the bottom line with the status and the key help.
+/// Inputs: frame, the footer area and the app.
+/// Output: none.
+/// Side effects: writes to the frame buffer.
+fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
+    let line = Line::from(vec![
+        Span::raw(FOOTER_MARGIN),
+        Span::styled(app.status.clone(), Style::new().add_modifier(Modifier::BOLD)),
+        Span::raw(FOOTER_GAP),
+        Span::styled(KEY_HELP, Style::new().add_modifier(Modifier::DIM)),
+    ]);
+
+    frame.render_widget(Paragraph::new(line), area);
+}
+
+#[cfg(test)]
+mod tests {
+    use chrono::{FixedOffset, Utc};
+
+    use super::*;
+
+    /// Timestamp of 2024-01-01 10:02:00 UTC.
+    const SAMPLE_TIMESTAMP: i64 = 1_704_103_320;
+    /// Offset of UTC+1 in seconds.
+    const PLUS_ONE_HOUR: i32 = 3600;
+    /// A plausible WhatsApp pairing payload.
+    const SAMPLE_QR_PAYLOAD: &str = "2@abcdEFGH1234,xyz+/=,pubkey==,adv==";
+
+    /// Builds an incoming message with the sample timestamp.
+    fn incoming(text: &str) -> StoredMessage {
+        StoredMessage {
+            message_id: "m1".to_string(),
+            chat_jid: "393331111111@s.whatsapp.net".to_string(),
+            sender_jid: "393331111111@s.whatsapp.net".to_string(),
+            sender_name: Some("Mario".to_string()),
+            text: text.to_string(),
+            timestamp: SAMPLE_TIMESTAMP,
+            from_me: false,
+            is_ai: false,
+        }
+    }
+
+    #[test]
+    fn incoming_message_uses_sender_name() {
+        let line = format_message_line_in(&incoming("ciao"), &Utc);
+
+        assert_eq!(line, "10:02 Mario: ciao");
+    }
+
+    #[test]
+    fn sender_name_falls_back_to_jid_user_part() {
+        let mut message = incoming("ciao");
+        message.sender_name = None;
+        assert_eq!(format_message_line_in(&message, &Utc), "10:02 393331111111: ciao");
+
+        message.sender_name = Some("   ".to_string());
+        assert_eq!(format_message_line_in(&message, &Utc), "10:02 393331111111: ciao");
+    }
+
+    #[test]
+    fn own_and_ai_messages_are_labelled() {
+        let mut message = incoming("ok");
+        message.from_me = true;
+        assert_eq!(format_message_line_in(&message, &Utc), "10:02 Tu: ok");
+
+        message.is_ai = true;
+        assert_eq!(format_message_line_in(&message, &Utc), "10:02 [AI] Tu: ok");
+    }
+
+    #[test]
+    fn clock_time_honors_zone_and_range() {
+        let zone = FixedOffset::east_opt(PLUS_ONE_HOUR).unwrap();
+
+        assert_eq!(format_clock_time(SAMPLE_TIMESTAMP, &zone), "11:02");
+        assert_eq!(format_clock_time(i64::MAX, &Utc), CLOCK_PLACEHOLDER);
+    }
+
+    #[test]
+    fn qr_text_is_not_empty_and_multiline() {
+        let text = qr_to_text(SAMPLE_QR_PAYLOAD).unwrap();
+
+        assert!(!text.is_empty());
+        assert!(text.lines().count() > 1);
+    }
+
+    #[test]
+    fn qr_block_reports_errors_and_missing_code() {
+        let oversized = "x".repeat(10_000);
+        let error_line = qr_block_lines(Some(&oversized), 100, 200);
+        assert_eq!(error_line.len(), 1);
+        assert!(error_line[0].to_string().starts_with(TEXT_QR_ERROR));
+
+        assert_eq!(qr_block_lines(None, 100, 200)[0].to_string(), TEXT_QR_WAITING);
+        assert_eq!(
+            qr_block_lines(Some(SAMPLE_QR_PAYLOAD), 1, 1)[0].to_string(),
+            TEXT_QR_TOO_SMALL
+        );
+    }
+
+    #[test]
+    fn wrap_splits_long_text_and_newlines() {
+        assert_eq!(wrap_text("abcdef", 4), ["abcd", "ef"]);
+        assert_eq!(wrap_text("ab\ncd", 10), ["ab", "cd"]);
+        assert_eq!(wrap_text("", 10), [""]);
+    }
+
+    #[test]
+    fn visible_lines_keep_the_latest() {
+        let messages = [incoming("one"), incoming("two"), incoming("three")];
+
+        let lines = visible_message_lines(&messages, 80, 2);
+
+        assert_eq!(lines.len(), 2);
+        assert!(lines[1].to_string().ends_with("three"));
+        assert!(lines[0].to_string().ends_with("two"));
+    }
+
+    #[test]
+    fn tail_fitting_keeps_the_end() {
+        assert_eq!(tail_fitting("> hello", 3), "llo");
+        assert_eq!(tail_fitting("> hi", 10), "> hi");
+        assert_eq!(tail_fitting("abc", 0), "");
+    }
+
+    #[test]
+    fn messages_title_shows_name_and_ai_state() {
+        assert_eq!(messages_title(None, "", false), TITLE_NO_CHAT);
+
+        let off = messages_title(Some("a@s.whatsapp.net"), "Mario", false);
+        assert!(off.contains("Mario"));
+        assert!(off.contains("AI: OFF"));
+
+        let on = messages_title(Some("a@s.whatsapp.net"), "Mario", true);
+        assert!(on.contains("AI: ON"));
+    }
+
+    #[test]
+    fn selected_index_is_clamped() {
+        assert_eq!(selected_index(0, 0), None);
+        assert_eq!(selected_index(0, 9), None);
+        assert_eq!(selected_index(2, 0), Some(0));
+        assert_eq!(selected_index(2, 9), Some(1));
+    }
+}

+ 1173 - 0
src/whatsapp.rs

@@ -0,0 +1,1173 @@
+//! Thin wrapper around the `whatsapp-rust` linked-device client.
+//!
+//! The wrapper starts the client in the background, translates the library
+//! events into [`WaEvent`]s delivered on an unbounded mpsc channel and exposes
+//! [`WaHandle::send_text`] for outgoing text.
+//!
+//! Notes on coverage:
+//! - Live messages, offline-drain messages and messages sent from the phone
+//!   (`from_me = true`) are emitted as [`WaEvent::Message`].
+//! - History-sync chunks are decoded and their conversations' messages are
+//!   emitted as [`WaEvent::Message`] too.
+//! - Messages sent through [`WaHandle::send_text`] are NOT echoed back by the
+//!   server to this device, so the caller must persist them itself.
+//! - Contact names come from: app-state contact sync, history-sync push names /
+//!   inline contacts / conversation names, and the push name attached to each
+//!   inbound message (the library never emits a dedicated push-name event).
+//! - Chats are keyed by phone-number JIDs whenever the LID -> PN mapping is known.
+//! - The wrapper never writes to stdout/stderr: every problem is reported as
+//!   [`WaEvent::Error`].
+
+use std::collections::HashMap;
+use std::path::Path;
+use std::sync::{Arc, Mutex, MutexGuard};
+
+use anyhow::{Context, Result, anyhow, bail};
+use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
+use whatsapp_rust::prelude::*;
+use whatsapp_rust::types::events::{
+    ConnectFailure, ContactUpdate, LazyHistorySync, MessageBatch, PushNameUpdate,
+};
+use whatsapp_rust::wacore_binary::jid::JidExt;
+
+use crate::history::{Contact, StoredMessage};
+
+/// Text stored for media messages that carry no caption.
+const MEDIA_PLACEHOLDER: &str = "[media]";
+
+/// Text stored for messages whose content type is not rendered by this client.
+const UNSUPPORTED_PLACEHOLDER: &str = "[messaggio non supportato]";
+
+/// Text stored for any audio message (including push-to-talk voice notes).
+const VOICE_PLACEHOLDER: &str = "[messaggio vocale]";
+
+/// Text stored for image messages; a caption is appended after a space.
+const IMAGE_PLACEHOLDER: &str = "[immagine]";
+
+/// MIME type assumed when an image message does not declare one.
+const DEFAULT_IMAGE_MIME: &str = "image/jpeg";
+
+/// Event kinds the forwarder subscribes to; every other kind is never materialized.
+const SUBSCRIBED_KINDS: &[EventKind] = &[
+    EventKind::Connected,
+    EventKind::Disconnected,
+    EventKind::LoggedOut,
+    EventKind::PairingQrCode,
+    EventKind::PairingQrCodesExhausted,
+    EventKind::PairError,
+    EventKind::ClientOutdated,
+    EventKind::StreamReplaced,
+    EventKind::TemporaryBan,
+    EventKind::ConnectFailure,
+    EventKind::StreamError,
+    EventKind::Messages,
+    EventKind::UndecryptableMessage,
+    EventKind::HistorySync,
+    EventKind::ContactUpdate,
+    EventKind::PushNameUpdate,
+    EventKind::AppStateSyncFailed,
+];
+
+/// JID prefix of the status-broadcast pseudo chat, skipped in history.
+const STATUS_BROADCAST_PREFIX: &str = "status@broadcast";
+
+/// Event emitted when the client run loop ends without a more specific cause.
+const CLIENT_STOPPED_REASON: &str = "client stopped";
+
+/// Reason reported when another session replaced this one.
+const STREAM_REPLACED_REASON: &str = "stream replaced by another session";
+
+/// Event emitted by the WhatsApp client wrapper.
+#[derive(Debug, Clone)]
+pub enum WaEvent {
+    /// QR payload to render (pairing).
+    Qr(String),
+    /// The client is connected and authenticated.
+    Connected,
+    /// The connection dropped; the payload is the reason.
+    Disconnected(String),
+    /// The device was unlinked from the phone.
+    LoggedOut,
+    /// Live, history-sync or phone-sent message (`is_ai` is always false).
+    /// `image` is `Some` only for live image messages (never for history sync).
+    Message {
+        /// The message to persist.
+        message: StoredMessage,
+        /// Reference to download the image bytes on demand.
+        image: Option<ImageRef>,
+    },
+    /// Contact name learned from contact sync, history sync or push names.
+    Contact(Contact),
+    /// Non-fatal or fatal problem worth showing to the user.
+    Error(String),
+}
+
+/// Reference to a live image message, enough to download and decrypt its bytes.
+#[derive(Clone)]
+pub struct ImageRef {
+    /// Image proto carrying the CDN path and media key.
+    message: Arc<wa::message::ImageMessage>,
+}
+
+impl std::fmt::Debug for ImageRef {
+    /// Formats the reference without any binary or key material.
+    ///
+    /// Inputs: the formatter. Output: the formatting result. Side effects: none.
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("ImageRef")
+            .field("mimetype", &self.message.mimetype)
+            .field("file_length", &self.message.file_length)
+            .finish_non_exhaustive()
+    }
+}
+
+/// Decrypted image content downloaded on demand.
+#[derive(Debug, Clone)]
+pub struct DownloadedImage {
+    /// Plain image bytes (never written to disk by this module).
+    pub bytes: Vec<u8>,
+    /// MIME type from the message, `image/jpeg` when absent.
+    pub mime: String,
+}
+
+/// Handle used to send messages through the running client.
+#[derive(Clone)]
+pub struct WaHandle {
+    /// Shared client driving the connection.
+    client: Arc<Client>,
+}
+
+impl WaHandle {
+    /// Sends a plain text message.
+    ///
+    /// Inputs: `chat_jid` is a JID in `user@server` form, `text` the body (non-empty).
+    /// Output: the id of the sent message.
+    /// Side effects: encrypts and transmits the message over the network.
+    pub async fn send_text(&self, chat_jid: &str, text: &str) -> Result<String> {
+        if text.is_empty() {
+            bail!("cannot send an empty message");
+        }
+        let jid: Jid = chat_jid
+            .parse()
+            .map_err(|e| anyhow!("invalid JID '{chat_jid}': {e}"))?;
+        let sent = self
+            .client
+            .send_message(jid, wa::Message::text(text))
+            .await
+            .with_context(|| format!("failed to send message to {chat_jid}"))?;
+        Ok(sent.message_id)
+    }
+
+    /// Downloads and decrypts an image.
+    ///
+    /// Inputs: the reference received in [`WaEvent::Message`].
+    /// Output: the plain bytes and MIME type.
+    /// Side effects: HTTPS request to the WhatsApp CDN; the bytes stay in memory.
+    pub async fn download_image(&self, image: &ImageRef) -> Result<DownloadedImage> {
+        let bytes = self
+            .client
+            .download(image.message.as_ref())
+            .await
+            .context("failed to download the image from WhatsApp")?;
+        let mime = image
+            .message
+            .mimetype
+            .clone()
+            .filter(|m| !m.trim().is_empty())
+            .unwrap_or_else(|| DEFAULT_IMAGE_MIME.to_owned());
+        Ok(DownloadedImage { bytes, mime })
+    }
+
+    /// Lists this account's own JIDs.
+    ///
+    /// Inputs: none (reads the client's device identity).
+    /// Output: the phone-number JID and the LID JID, whichever are known, in
+    /// `user@server` form without device suffix; empty before pairing completes.
+    /// Side effects: none.
+    pub fn own_jids(&self) -> Vec<String> {
+        [self.client.pn(), self.client.lid()]
+            .into_iter()
+            .flatten()
+            .map(|jid| jid_to_string(&jid))
+            .collect()
+    }
+}
+
+/// Starts the WhatsApp client in the background.
+///
+/// Inputs: `session_db` is the SQLite session file (created when missing),
+/// `events` receives every [`WaEvent`].
+/// Output: a [`WaHandle`] for sending messages.
+/// Side effects: creates the parent directory and the session database,
+/// spawns tokio tasks (client run loop and event translator) and opens the
+/// network connection. Pairing and connection results arrive as events.
+pub async fn start(session_db: &Path, events: UnboundedSender<WaEvent>) -> Result<WaHandle> {
+    let db_url = session_db
+        .to_str()
+        .ok_or_else(|| anyhow!("session database path is not valid UTF-8"))?;
+    if let Some(parent) = session_db.parent().filter(|p| !p.as_os_str().is_empty()) {
+        std::fs::create_dir_all(parent)
+            .with_context(|| format!("cannot create session directory {}", parent.display()))?;
+    }
+
+    let store = SqliteStore::new(db_url)
+        .await
+        .with_context(|| format!("cannot open session database {db_url}"))?;
+
+    let (raw_tx, raw_rx) = unbounded_channel::<Arc<Event>>();
+    let bot = Bot::builder()
+        .with_backend(store)
+        .with_event_handler(EventForwarder { tx: raw_tx })
+        .build()
+        .await
+        .context("cannot build the WhatsApp client")?;
+
+    let client = bot.client();
+    let translator = Arc::new(Translator::new(client.clone(), events));
+    tokio::spawn(Arc::clone(&translator).run(raw_rx));
+
+    let bot_handle = bot.spawn();
+    tokio::spawn(async move {
+        // Keeping the handle alive keeps the bot running; resolves when it stops.
+        bot_handle.await;
+        translator.emit(WaEvent::Disconnected(CLIENT_STOPPED_REASON.to_owned()));
+    });
+
+    Ok(WaHandle { client })
+}
+
+/// Event-bus handler that forwards raw library events, in order, to the translator task.
+struct EventForwarder {
+    /// Channel feeding the translator task.
+    tx: UnboundedSender<Arc<Event>>,
+}
+
+impl EventHandler for EventForwarder {
+    /// Queues an event for the translator task.
+    ///
+    /// Inputs: the shared event. Output: none.
+    /// Side effects: pushes onto an unbounded channel; if the translator is gone
+    /// (shutdown) the event is dropped because nobody is left to report it to.
+    fn handle_event(&self, event: Arc<Event>) {
+        self.tx.send(event).ok();
+    }
+
+    /// Restricts delivery to the kinds the translator understands.
+    ///
+    /// Inputs: none. Output: the interest set. Side effects: none.
+    fn interest(&self) -> EventInterest {
+        EventInterest::of(SUBSCRIBED_KINDS)
+    }
+}
+
+/// Confidence level of a contact name; a lower-ranked source never overrides a higher one.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+enum NameSource {
+    /// Name chosen by the contact itself (push name).
+    Push,
+    /// Name saved in the user's address book or group subject.
+    Saved,
+}
+
+/// Translates library events into [`WaEvent`]s.
+struct Translator {
+    /// Client used for LID/PN lookups and own identity.
+    client: Arc<Client>,
+    /// Destination of the translated events.
+    events: UnboundedSender<WaEvent>,
+    /// Last emitted name per JID, used to avoid duplicate contact events.
+    names: Mutex<HashMap<String, (NameSource, String)>>,
+}
+
+impl Translator {
+    /// Creates a translator.
+    ///
+    /// Inputs: the client and the output channel. Output: the translator.
+    /// Side effects: none.
+    fn new(client: Arc<Client>, events: UnboundedSender<WaEvent>) -> Self {
+        Self {
+            client,
+            events,
+            names: Mutex::new(HashMap::new()),
+        }
+    }
+
+    /// Sends an event to the application.
+    ///
+    /// Inputs: the event. Output: none.
+    /// Side effects: pushes onto the channel; a closed receiver means the
+    /// application is shutting down, so there is nobody to report the loss to.
+    fn emit(&self, event: WaEvent) {
+        self.events.send(event).ok();
+    }
+
+    /// Reports a problem as [`WaEvent::Error`].
+    ///
+    /// Inputs: the message. Output: none. Side effects: emits an event.
+    fn report(&self, message: String) {
+        self.emit(WaEvent::Error(message));
+    }
+
+    /// Locks the name table, recovering from a poisoned lock.
+    ///
+    /// Inputs: none. Output: the guard. Side effects: none.
+    fn lock_names(&self) -> MutexGuard<'_, HashMap<String, (NameSource, String)>> {
+        self.names
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner())
+    }
+
+    /// Emits a [`WaEvent::Contact`] when the name is new or better than the known one.
+    ///
+    /// Inputs: contact JID, name, and the source confidence.
+    /// Output: none.
+    /// Side effects: updates the name table and may emit an event.
+    fn note_name(&self, jid: &str, name: &str, source: NameSource) {
+        let name = name.trim();
+        if jid.is_empty() || name.is_empty() {
+            return;
+        }
+        {
+            let mut names = self.lock_names();
+            if let Some((known_source, known_name)) = names.get(jid)
+                && (*known_source > source || known_name == name)
+            {
+                return;
+            }
+            names.insert(jid.to_owned(), (source, name.to_owned()));
+        }
+        self.emit(WaEvent::Contact(Contact {
+            jid: jid.to_owned(),
+            name: name.to_owned(),
+        }));
+    }
+
+    /// Returns this account's phone-number JID in string form.
+    ///
+    /// Inputs: none. Output: the JID, or an empty string before pairing completes.
+    /// Side effects: none.
+    fn own_jid_string(&self) -> String {
+        self.client
+            .pn()
+            .map(|jid| jid_to_string(&jid))
+            .unwrap_or_default()
+    }
+
+    /// Resolves a user JID to its canonical string, preferring the phone-number form.
+    ///
+    /// Inputs: the JID and an optional alternate JID supplied by the stanza.
+    /// Output: `user@server` without device suffix.
+    /// Side effects: reads the LID/PN mapping store; lookup failures are reported
+    /// as events and the LID form is returned.
+    async fn canonical_jid(&self, jid: &Jid, alt: Option<&Jid>) -> String {
+        if !jid.is_lid() {
+            return jid_to_string(jid);
+        }
+        if let Some(alt) = alt.filter(|alt| alt.is_pn()) {
+            return jid_to_string(alt);
+        }
+        match self.client.get_lid_pn_entry(jid).await {
+            Ok(Some(entry)) => jid_to_string(&Jid::pn(&*entry.phone_number)),
+            Ok(None) => jid_to_string(jid),
+            Err(e) => {
+                self.report(format!("LID lookup failed for {}: {e}", jid_to_string(jid)));
+                jid_to_string(jid)
+            }
+        }
+    }
+
+    /// String variant of [`Translator::canonical_jid`].
+    ///
+    /// Inputs: the raw JID text and an optional alternate JID text.
+    /// Output: the canonical string; unparseable input is reported and returned as is.
+    /// Side effects: as [`Translator::canonical_jid`].
+    async fn canonical_str(&self, raw: &str, alt: Option<&str>) -> String {
+        let jid: Jid = match raw.parse() {
+            Ok(jid) => jid,
+            Err(e) => {
+                self.report(format!("invalid JID '{raw}' in synced data: {e}"));
+                return raw.to_owned();
+            }
+        };
+        let alt_jid = alt.and_then(|a| a.parse::<Jid>().ok());
+        self.canonical_jid(&jid, alt_jid.as_ref()).await
+    }
+
+    /// Consumes raw library events until the sender side is dropped.
+    ///
+    /// Inputs: the receiving end of the forwarder channel. Output: none.
+    /// Side effects: emits [`WaEvent`]s; spawns a task per history-sync chunk.
+    async fn run(self: Arc<Self>, mut raw_rx: UnboundedReceiver<Arc<Event>>) {
+        while let Some(event) = raw_rx.recv().await {
+            self.handle_event(&event).await;
+        }
+    }
+
+    /// Translates one library event.
+    ///
+    /// Inputs: the event. Output: none. Side effects: emits zero or more events.
+    async fn handle_event(self: &Arc<Self>, event: &Event) {
+        match event {
+            Event::PairingQrCode(qr) => self.emit(WaEvent::Qr(qr.code.clone())),
+            Event::PairingQrCodesExhausted(_) => {
+                self.report("pairing QR codes exhausted; restart to pair again".to_owned());
+            }
+            Event::PairError(e) => self.report(format!("pairing failed: {}", e.error)),
+            Event::Connected(_) => self.emit(WaEvent::Connected),
+            Event::Disconnected(d) => self.emit(WaEvent::Disconnected(d.reason.to_string())),
+            Event::StreamReplaced(_) => {
+                self.emit(WaEvent::Disconnected(STREAM_REPLACED_REASON.to_owned()));
+            }
+            Event::LoggedOut(_) => self.emit(WaEvent::LoggedOut),
+            Event::ClientOutdated(_) => {
+                self.report("WhatsApp rejected this client version (client outdated)".to_owned());
+            }
+            Event::TemporaryBan(ban) => self.report(format!(
+                "account temporarily banned: {} (expires in {}s)",
+                ban.code,
+                ban.expire.num_seconds()
+            )),
+            Event::ConnectFailure(failure) => self.report(describe_connect_failure(failure)),
+            Event::StreamError(e) => self.report(format!("stream error: {}", e.code)),
+            Event::AppStateSyncFailed(f) => self.report(format!(
+                "app state sync incomplete (fatal: {:?}, retryable: {:?})",
+                f.fatal, f.retryable
+            )),
+            Event::UndecryptableMessage(u) => self.report(format!(
+                "could not decrypt a message from {}",
+                jid_to_string(&u.info.source.sender)
+            )),
+            Event::Messages(batch) => self.handle_batch(batch).await,
+            Event::HistorySync(lazy) => {
+                tokio::spawn(Arc::clone(self).handle_history((**lazy).clone()));
+            }
+            Event::ContactUpdate(update) => self.handle_contact_update(update).await,
+            Event::PushNameUpdate(update) => self.handle_push_name_update(update).await,
+            _ => {}
+        }
+    }
+
+    /// Translates a batch of decrypted inbound messages.
+    ///
+    /// Inputs: the batch. Output: none. Side effects: emits message/contact events.
+    async fn handle_batch(&self, batch: &MessageBatch) {
+        for inbound in batch {
+            self.handle_live_message(inbound).await;
+        }
+    }
+
+    /// Translates one decrypted message (live, offline-drained or sent from the phone).
+    ///
+    /// Inputs: the inbound message. Output: none.
+    /// Side effects: emits a message event and possibly a contact event; protocol,
+    /// reaction and status-broadcast messages produce nothing.
+    async fn handle_live_message(&self, inbound: &InboundMessage) {
+        let info = &inbound.info;
+        let source = &info.source;
+        if source.chat.is_status_broadcast() {
+            return;
+        }
+        let Some(text) = describe_message(&inbound.message) else {
+            return;
+        };
+
+        let chat_alt = if source.is_from_me {
+            source.recipient_alt.as_ref()
+        } else {
+            source.sender_alt.as_ref()
+        };
+        let chat_jid = if source.chat.is_group() {
+            jid_to_string(&source.chat)
+        } else {
+            self.canonical_jid(&source.chat, chat_alt).await
+        };
+        let sender_jid = self
+            .canonical_jid(&source.sender, source.sender_alt.as_ref())
+            .await;
+        let sender_name = push_name_of(&info.push_name, source.is_from_me);
+        if let Some(name) = &sender_name {
+            self.note_name(&sender_jid, name, NameSource::Push);
+        }
+
+        let image = if is_image(&text) {
+            image_ref_of(&inbound.message)
+        } else {
+            None
+        };
+        self.emit(WaEvent::Message {
+            message: StoredMessage {
+                message_id: info.id.to_string(),
+                chat_jid,
+                sender_jid,
+                sender_name,
+                text,
+                timestamp: info.timestamp.timestamp(),
+                from_me: source.is_from_me,
+                is_ai: false,
+            },
+            image,
+        });
+    }
+
+    /// Translates an app-state contact sync action into a contact event.
+    ///
+    /// Inputs: the update. Output: none. Side effects: may emit a contact event.
+    async fn handle_contact_update(&self, update: &ContactUpdate) {
+        let action = &update.action;
+        let name = action
+            .full_name
+            .as_deref()
+            .filter(|n| !n.trim().is_empty())
+            .or(action.first_name.as_deref());
+        let Some(name) = name else {
+            return;
+        };
+        let jid = self
+            .canonical_str(&update.jid.to_string(), action.pn_jid.as_deref())
+            .await;
+        self.note_name(&jid, name, NameSource::Saved);
+    }
+
+    /// Translates a push-name change into a contact event.
+    ///
+    /// Inputs: the update. Output: none. Side effects: may emit a contact event.
+    async fn handle_push_name_update(&self, update: &PushNameUpdate) {
+        let jid = self.canonical_jid(&update.jid, None).await;
+        self.note_name(&jid, &update.new_push_name, NameSource::Push);
+    }
+
+    /// Decodes a history-sync chunk and emits its messages and contacts.
+    ///
+    /// Inputs: the lazily-compressed chunk (cloned, cheap). Output: none.
+    /// Side effects: inflates and decodes on a blocking thread, then emits events;
+    /// decode failures are reported as errors.
+    async fn handle_history(self: Arc<Self>, lazy: LazyHistorySync) {
+        let extracted = tokio::task::spawn_blocking(move || extract_history(&lazy)).await;
+        let extract = match extracted {
+            Ok(Ok(extract)) => extract,
+            Ok(Err(message)) => {
+                self.report(message);
+                return;
+            }
+            Err(e) => {
+                self.report(format!("history sync worker failed: {e}"));
+                return;
+            }
+        };
+        if extract.skipped_conversations > 0 {
+            self.report(format!(
+                "history sync skipped {} undecodable conversations",
+                extract.skipped_conversations
+            ));
+        }
+        self.emit_history(extract).await;
+    }
+
+    /// Emits the contents of a decoded history-sync chunk.
+    ///
+    /// Inputs: the extracted data. Output: none.
+    /// Side effects: emits contact and message events.
+    async fn emit_history(&self, extract: HistoryExtract) {
+        for (id, push_name) in &extract.pushnames {
+            let jid = self.canonical_str(id, None).await;
+            self.note_name(&jid, push_name, NameSource::Push);
+        }
+        for contact in &extract.inline_contacts {
+            let Some(raw) = contact.pn_jid.as_ref().or(contact.lid_jid.as_ref()) else {
+                continue;
+            };
+            let alt = contact
+                .pn_jid
+                .as_deref()
+                .filter(|_| contact.lid_jid.is_some());
+            let jid = self.canonical_str(raw, alt).await;
+            self.note_name(&jid, &contact.name, NameSource::Saved);
+        }
+
+        let own_jid = self.own_jid_string();
+        for conversation in extract.conversations {
+            self.emit_history_conversation(conversation, &own_jid).await;
+        }
+    }
+
+    /// Emits one history-sync conversation.
+    ///
+    /// Inputs: the conversation and this account's JID.
+    /// Output: none.
+    /// Side effects: emits a contact event for the chat name and a message event per message.
+    async fn emit_history_conversation(&self, conversation: HistoryConversation, own_jid: &str) {
+        let chat_jid = self
+            .canonical_str(&conversation.id, conversation.pn_jid.as_deref())
+            .await;
+        if chat_jid.starts_with(STATUS_BROADCAST_PREFIX) {
+            return;
+        }
+        if let Some(name) = &conversation.name {
+            self.note_name(&chat_jid, name, NameSource::Saved);
+        }
+
+        for message in conversation.messages {
+            let sender_jid = if message.from_me {
+                own_jid.to_owned()
+            } else if let Some(participant) = &message.participant {
+                self.canonical_str(participant, None).await
+            } else {
+                chat_jid.clone()
+            };
+            let sender_name =
+                push_name_of(message.push_name.as_deref().unwrap_or(""), message.from_me);
+            if let Some(name) = &sender_name {
+                self.note_name(&sender_jid, name, NameSource::Push);
+            }
+            self.emit(WaEvent::Message {
+                message: StoredMessage {
+                    message_id: message.id,
+                    chat_jid: chat_jid.clone(),
+                    sender_jid,
+                    sender_name,
+                    text: message.text,
+                    timestamp: message.timestamp,
+                    from_me: message.from_me,
+                    is_ai: false,
+                },
+                image: None,
+            });
+        }
+    }
+}
+
+/// One message extracted from a history-sync conversation.
+struct HistoryMessage {
+    /// Message id.
+    id: String,
+    /// True when sent by this account.
+    from_me: bool,
+    /// Author JID for group messages.
+    participant: Option<String>,
+    /// Push name of the author, when known.
+    push_name: Option<String>,
+    /// Unix timestamp in seconds.
+    timestamp: i64,
+    /// Rendered text.
+    text: String,
+}
+
+/// One conversation extracted from a history-sync chunk.
+struct HistoryConversation {
+    /// Raw conversation JID.
+    id: String,
+    /// Phone-number JID when `id` is a LID.
+    pn_jid: Option<String>,
+    /// Saved contact name or group subject.
+    name: Option<String>,
+    /// Renderable messages.
+    messages: Vec<HistoryMessage>,
+}
+
+/// Contact carried inline by a history-sync chunk.
+struct HistoryInlineContact {
+    /// Phone-number JID.
+    pn_jid: Option<String>,
+    /// LID JID.
+    lid_jid: Option<String>,
+    /// Best available name.
+    name: String,
+}
+
+/// Everything of interest extracted from one history-sync chunk.
+struct HistoryExtract {
+    /// Conversations with their messages.
+    conversations: Vec<HistoryConversation>,
+    /// `(jid, push name)` pairs.
+    pushnames: Vec<(String, String)>,
+    /// Address-book contacts.
+    inline_contacts: Vec<HistoryInlineContact>,
+    /// Conversations the decoder could not parse.
+    skipped_conversations: usize,
+}
+
+/// Decodes a history-sync chunk into plain data.
+///
+/// Inputs: the lazily-compressed chunk. Output: the extracted data, or an error text.
+/// Side effects: CPU-bound inflate and protobuf decode (call from a blocking thread).
+fn extract_history(lazy: &LazyHistorySync) -> Result<HistoryExtract, String> {
+    let mut stream = lazy.stream();
+    let mut conversations = Vec::new();
+    loop {
+        let conversation = stream
+            .next_conversation()
+            .map_err(|e| format!("history sync decode failed: {e}"))?;
+        let Some(conversation) = conversation else {
+            break;
+        };
+        conversations.push(extract_conversation(&conversation));
+    }
+    let skipped_conversations = stream.skipped_conversations();
+    let rest = stream
+        .remainder()
+        .map_err(|e| format!("history sync remainder decode failed: {e}"))?;
+
+    let pushnames = rest
+        .pushnames
+        .iter()
+        .filter_map(|p| Some((p.id.clone()?, p.pushname.clone()?)))
+        .collect();
+    let inline_contacts = rest
+        .inline_contacts
+        .iter()
+        .filter_map(|c| {
+            let name = c.full_name.clone().or_else(|| c.first_name.clone())?;
+            Some(HistoryInlineContact {
+                pn_jid: c.pn_jid.clone(),
+                lid_jid: c.lid_jid.clone(),
+                name,
+            })
+        })
+        .collect();
+
+    Ok(HistoryExtract {
+        conversations,
+        pushnames,
+        inline_contacts,
+        skipped_conversations,
+    })
+}
+
+/// Converts a protobuf conversation into plain data.
+///
+/// Inputs: the conversation. Output: id, name and renderable messages.
+/// Side effects: none.
+fn extract_conversation(conversation: &wa::Conversation) -> HistoryConversation {
+    let name = conversation
+        .name
+        .clone()
+        .or_else(|| conversation.display_name.clone());
+    let messages = conversation
+        .messages
+        .iter()
+        .filter_map(|m| extract_history_message(m.message.as_option()?))
+        .collect();
+    HistoryConversation {
+        id: conversation.id.clone(),
+        pn_jid: conversation.pn_jid.clone(),
+        name,
+        messages,
+    }
+}
+
+/// Converts a protobuf history message into plain data.
+///
+/// Inputs: the web message info. Output: the message, or `None` when it has no
+/// renderable content (stub, protocol, reaction) or no id.
+/// Side effects: none.
+fn extract_history_message(info: &wa::WebMessageInfo) -> Option<HistoryMessage> {
+    let key = info.key.as_option()?;
+    let id = key.id.clone().filter(|id| !id.is_empty())?;
+    let text = describe_message(info.message.as_option()?)?;
+    let participant = key
+        .participant
+        .clone()
+        .or_else(|| info.participant.clone())
+        .filter(|p| !p.is_empty());
+    Some(HistoryMessage {
+        id,
+        from_me: key.from_me.unwrap_or(false),
+        participant,
+        push_name: info.push_name.clone().filter(|n| !n.is_empty()),
+        timestamp: i64::try_from(info.message_timestamp.unwrap_or_default()).unwrap_or_default(),
+        text,
+    })
+}
+
+/// Renders a connect failure as an error text.
+///
+/// Inputs: the failure. Output: the text. Side effects: none.
+fn describe_connect_failure(failure: &ConnectFailure) -> String {
+    match &failure.message {
+        Some(message) => format!("connection failure: {:?} ({message})", failure.reason),
+        None => format!("connection failure: {:?}", failure.reason),
+    }
+}
+
+/// Returns the sender display name for a message.
+///
+/// Inputs: the push name and whether the message was sent by this account.
+/// Output: the name, or `None` for own messages and empty names.
+/// Side effects: none.
+fn push_name_of(push_name: &str, from_me: bool) -> Option<String> {
+    let trimmed = push_name.trim();
+    (!from_me && !trimmed.is_empty()).then(|| trimmed.to_owned())
+}
+
+/// Formats a JID as `user@server` without device suffix.
+///
+/// Inputs: the JID. Output: the canonical string. Side effects: none.
+fn jid_to_string(jid: &Jid) -> String {
+    jid.to_non_ad_string()
+}
+
+/// Extracts displayable text from a message.
+///
+/// Inputs: the decoded message.
+/// Output: the text (plain text, image/voice text, media caption, or a
+/// placeholder), or `None` for protocol, reaction, poll-vote and
+/// key-distribution messages that must not appear in a chat.
+/// Side effects: none.
+fn describe_message(message: &wa::Message) -> Option<String> {
+    if let Some(text) = message.text_content().filter(|t| !t.is_empty()) {
+        return Some(text.to_owned());
+    }
+    let base = message.get_base_message();
+    if let Some(text) = describe_image_or_audio(base) {
+        return Some(text);
+    }
+    if let Some(caption) = message.get_caption().filter(|c| !c.is_empty()) {
+        return Some(caption.to_owned());
+    }
+    describe_other(base)
+}
+
+/// Renders image and audio messages, which have dedicated placeholders.
+///
+/// Inputs: the unwrapped base message.
+/// Output: `[immagine]` (plus ` <caption>` when non-empty) for images,
+/// `[messaggio vocale]` for audio, `None` for anything else.
+/// Side effects: none.
+fn describe_image_or_audio(base: &wa::Message) -> Option<String> {
+    if let Some(image) = base.image_message.as_option() {
+        let caption = image.caption.as_deref().map(str::trim).unwrap_or_default();
+        return Some(if caption.is_empty() {
+            IMAGE_PLACEHOLDER.to_owned()
+        } else {
+            format!("{IMAGE_PLACEHOLDER} {caption}")
+        });
+    }
+    base.audio_message
+        .as_option()
+        .map(|_| VOICE_PLACEHOLDER.to_owned())
+}
+
+/// Renders every message that is not plain text or captioned media.
+///
+/// Inputs: the unwrapped base message.
+/// Output: image/voice text, a media or unsupported placeholder, or `None` for
+/// silent kinds (protocol, reaction, poll vote, key distribution).
+/// Side effects: none.
+fn describe_other(base: &wa::Message) -> Option<String> {
+    let is_silent = base.protocol_message.as_option().is_some()
+        || base.reaction_message.as_option().is_some()
+        || base.enc_reaction_message.as_option().is_some()
+        || base.poll_update_message.as_option().is_some()
+        || base.sender_key_distribution_message.as_option().is_some();
+    if is_silent {
+        return None;
+    }
+
+    let is_media = base.video_message.as_option().is_some()
+        || base.document_message.as_option().is_some()
+        || base.sticker_message.as_option().is_some()
+        || base.ptv_message.as_option().is_some();
+    if is_media {
+        return Some(MEDIA_PLACEHOLDER.to_owned());
+    }
+    Some(UNSUPPORTED_PLACEHOLDER.to_owned())
+}
+
+/// Extracts a downloadable reference when the message is an image.
+///
+/// Inputs: the decoded message.
+/// Output: the [`ImageRef`], or `None` when the message carries no image.
+/// Side effects: clones the image proto (no network access).
+fn image_ref_of(message: &wa::Message) -> Option<ImageRef> {
+    message
+        .get_base_message()
+        .image_message
+        .as_option()
+        .map(|image| ImageRef {
+            message: Arc::new(image.clone()),
+        })
+}
+
+/// Tells whether a message text is one of the placeholders generated for
+/// media without caption or for unsupported content.
+///
+/// Inputs: the message text.
+/// Output: `true` iff the trimmed text equals the media or the unsupported
+/// placeholder exactly; `false` for any other text (voice and image
+/// placeholders are NOT placeholders in this sense).
+/// Side effects: none.
+pub fn is_placeholder(text: &str) -> bool {
+    let trimmed = text.trim();
+    trimmed == MEDIA_PLACEHOLDER || trimmed == UNSUPPORTED_PLACEHOLDER
+}
+
+/// Tells whether a message text denotes a voice note / audio message.
+///
+/// Inputs: the message text.
+/// Output: `true` iff the trimmed text equals the voice placeholder.
+/// Side effects: none.
+pub fn is_voice_note(text: &str) -> bool {
+    text.trim() == VOICE_PLACEHOLDER
+}
+
+/// Tells whether a message text denotes an image message (with or without caption).
+///
+/// Inputs: the message text.
+/// Output: `true` iff the trimmed text is the image placeholder alone or the
+/// image placeholder followed by a space and a caption.
+/// Side effects: none.
+pub fn is_image(text: &str) -> bool {
+    image_caption_part(text.trim()).is_some()
+}
+
+/// Returns the caption of an image message text.
+///
+/// Inputs: the message text.
+/// Output: the trimmed caption, or `None` when the text is not an image
+/// message or the caption is empty.
+/// Side effects: none.
+pub fn image_caption(text: &str) -> Option<&str> {
+    image_caption_part(text.trim())
+        .map(str::trim)
+        .filter(|caption| !caption.is_empty())
+}
+
+/// Splits an already-trimmed image text into the part after the placeholder.
+///
+/// Inputs: the trimmed text.
+/// Output: the remainder (possibly empty) when the text is an image text, else `None`.
+/// Side effects: none.
+fn image_caption_part(trimmed: &str) -> Option<&str> {
+    let rest = trimmed.strip_prefix(IMAGE_PLACEHOLDER)?;
+    (rest.is_empty() || rest.starts_with(' ')).then_some(rest)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Builds a message with the given plain text.
+    fn text_message(text: &str) -> wa::Message {
+        wa::Message {
+            conversation: Some(text.to_owned()),
+            ..Default::default()
+        }
+    }
+
+    /// Builds an image message with an optional caption.
+    fn image_message(caption: Option<&str>) -> wa::Message {
+        wa::Message {
+            image_message: MessageField::some(wa::message::ImageMessage {
+                caption: caption.map(str::to_owned),
+                ..Default::default()
+            }),
+            ..Default::default()
+        }
+    }
+
+    #[test]
+    fn plain_text_is_extracted() {
+        assert_eq!(
+            describe_message(&text_message("ciao")),
+            Some("ciao".to_owned())
+        );
+    }
+
+    #[test]
+    fn extended_text_is_extracted() {
+        let message = wa::Message {
+            extended_text_message: MessageField::some(wa::message::ExtendedTextMessage {
+                text: Some("link".to_owned()),
+                ..Default::default()
+            }),
+            ..Default::default()
+        };
+        assert_eq!(describe_message(&message), Some("link".to_owned()));
+    }
+
+    /// Builds a video message with an optional caption.
+    fn video_message(caption: Option<&str>) -> wa::Message {
+        wa::Message {
+            video_message: MessageField::some(wa::message::VideoMessage {
+                caption: caption.map(str::to_owned),
+                ..Default::default()
+            }),
+            ..Default::default()
+        }
+    }
+
+    #[test]
+    fn media_caption_is_used_when_present() {
+        assert_eq!(
+            describe_message(&video_message(Some("clip"))),
+            Some("clip".to_owned())
+        );
+    }
+
+    #[test]
+    fn media_without_caption_gets_placeholder() {
+        assert_eq!(
+            describe_message(&video_message(None)),
+            Some(MEDIA_PLACEHOLDER.to_owned())
+        );
+    }
+
+    #[test]
+    fn image_without_caption_gets_image_placeholder() {
+        assert_eq!(
+            describe_message(&image_message(None)),
+            Some(IMAGE_PLACEHOLDER.to_owned())
+        );
+        assert_eq!(
+            describe_message(&image_message(Some("  "))),
+            Some(IMAGE_PLACEHOLDER.to_owned())
+        );
+    }
+
+    #[test]
+    fn image_with_caption_appends_caption() {
+        assert_eq!(
+            describe_message(&image_message(Some("foto"))),
+            Some("[immagine] foto".to_owned())
+        );
+    }
+
+    #[test]
+    fn audio_gets_voice_placeholder() {
+        let message = wa::Message {
+            audio_message: MessageField::some(wa::message::AudioMessage {
+                ptt: Some(true),
+                ..Default::default()
+            }),
+            ..Default::default()
+        };
+        assert_eq!(
+            describe_message(&message),
+            Some(VOICE_PLACEHOLDER.to_owned())
+        );
+        let plain_audio = wa::Message {
+            audio_message: MessageField::some(wa::message::AudioMessage::default()),
+            ..Default::default()
+        };
+        assert_eq!(
+            describe_message(&plain_audio),
+            Some(VOICE_PLACEHOLDER.to_owned())
+        );
+    }
+
+    #[test]
+    fn plain_text_wins_over_image_placeholder() {
+        let mut message = image_message(Some("foto"));
+        message.conversation = Some("testo".to_owned());
+        assert_eq!(describe_message(&message), Some("testo".to_owned()));
+    }
+
+    #[test]
+    fn image_ref_is_extracted_only_from_images() {
+        assert!(image_ref_of(&image_message(None)).is_some());
+        assert!(image_ref_of(&video_message(None)).is_none());
+        assert!(image_ref_of(&text_message("ciao")).is_none());
+    }
+
+    #[test]
+    fn voice_note_detection() {
+        assert!(is_voice_note(VOICE_PLACEHOLDER));
+        assert!(is_voice_note(&format!(" {VOICE_PLACEHOLDER}\n")));
+        assert!(!is_voice_note("ciao"));
+        assert!(!is_voice_note(&format!("{VOICE_PLACEHOLDER} extra")));
+        assert!(!is_voice_note(IMAGE_PLACEHOLDER));
+    }
+
+    #[test]
+    fn image_detection() {
+        assert!(is_image(IMAGE_PLACEHOLDER));
+        assert!(is_image(&format!("  {IMAGE_PLACEHOLDER}\n")));
+        assert!(is_image("[immagine] una foto"));
+        assert!(!is_image("[immagine]foto"));
+        assert!(!is_image("guarda [immagine]"));
+        assert!(!is_image(VOICE_PLACEHOLDER));
+        assert!(!is_image("ciao"));
+        assert!(!is_image(""));
+    }
+
+    #[test]
+    fn image_caption_extraction() {
+        assert_eq!(image_caption("[immagine] una foto "), Some("una foto"));
+        assert_eq!(image_caption(IMAGE_PLACEHOLDER), None);
+        assert_eq!(image_caption("[immagine]   "), None);
+        assert_eq!(image_caption("[immagine]foto"), None);
+        assert_eq!(image_caption("ciao"), None);
+        assert_eq!(image_caption(VOICE_PLACEHOLDER), None);
+    }
+
+    #[test]
+    fn voice_and_image_are_not_placeholders() {
+        assert!(!is_placeholder(VOICE_PLACEHOLDER));
+        assert!(!is_placeholder(IMAGE_PLACEHOLDER));
+        assert!(!is_placeholder("[immagine] foto"));
+    }
+
+    #[test]
+    fn reaction_and_protocol_messages_are_ignored() {
+        let reaction = wa::Message {
+            reaction_message: MessageField::some(wa::message::ReactionMessage::default()),
+            ..Default::default()
+        };
+        let protocol = wa::Message {
+            protocol_message: MessageField::some(wa::message::ProtocolMessage::default()),
+            ..Default::default()
+        };
+        assert_eq!(describe_message(&reaction), None);
+        assert_eq!(describe_message(&protocol), None);
+    }
+
+    #[test]
+    fn unknown_content_gets_unsupported_placeholder() {
+        let message = wa::Message {
+            location_message: MessageField::some(wa::message::LocationMessage::default()),
+            ..Default::default()
+        };
+        assert_eq!(
+            describe_message(&message),
+            Some(UNSUPPORTED_PLACEHOLDER.to_owned())
+        );
+    }
+
+    #[test]
+    fn placeholders_are_detected() {
+        assert!(is_placeholder(MEDIA_PLACEHOLDER));
+        assert!(is_placeholder(UNSUPPORTED_PLACEHOLDER));
+        assert!(is_placeholder(&format!("  {MEDIA_PLACEHOLDER}\n")));
+    }
+
+    #[test]
+    fn normal_text_is_not_a_placeholder() {
+        assert!(!is_placeholder("ciao"));
+        assert!(!is_placeholder(""));
+    }
+
+    #[test]
+    fn placeholder_embedded_in_other_words_is_not_a_placeholder() {
+        assert!(!is_placeholder(&format!("guarda {MEDIA_PLACEHOLDER} qui")));
+        assert!(!is_placeholder(&format!("{UNSUPPORTED_PLACEHOLDER} grazie")));
+    }
+
+    #[test]
+    fn jid_drops_device_suffix() {
+        let jid = Jid::pn_device("393331234567", 7);
+        assert_eq!(jid_to_string(&jid), "393331234567@s.whatsapp.net");
+    }
+
+    #[test]
+    fn parsed_ad_jid_is_normalized() {
+        let jid: Jid = "393331234567:12@s.whatsapp.net".parse().expect("valid jid");
+        assert_eq!(jid_to_string(&jid), "393331234567@s.whatsapp.net");
+    }
+
+    #[test]
+    fn push_name_is_hidden_for_own_and_empty() {
+        assert_eq!(push_name_of("  Anna ", false), Some("Anna".to_owned()));
+        assert_eq!(push_name_of("Anna", true), None);
+        assert_eq!(push_name_of("  ", false), None);
+    }
+}